Commit 82ed9302 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo Committed by Mike Greiling

Fetch cycle analytics data from the new backend

For https://gitlab.com/gitlab-org/gitlab/issues/13076
we need to fetch cycle analytics data from
the /-/analytics dedicated endpoints introduced
in https://gitlab.com/gitlab-org/gitlab/merge_requests/18208

Remove unneeded lines
parent 2c93bad0
......@@ -138,6 +138,14 @@ export const stripHtml = (string, replace = '') => {
*/
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
* e.g. Hello World => Hello world
......
<script>
import { GlEmptyState, GlDaterangePicker } from '@gitlab/ui';
import { GlEmptyState, GlDaterangePicker, GlLoadingIcon } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
import { getDateInPast } from '~/lib/utils/datetime_utility';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
......@@ -14,6 +14,7 @@ export default {
name: 'CycleAnalytics',
components: {
GlEmptyState,
GlLoadingIcon,
GroupsDropdownFilter,
ProjectsDropdownFilter,
SummaryTable,
......@@ -49,7 +50,7 @@ export default {
'isAddingCustomStage',
'selectedGroup',
'selectedProjectIds',
'selectedStageName',
'selectedStageId',
'stages',
'summary',
'labels',
......@@ -83,7 +84,7 @@ export default {
},
methods: {
...mapActions([
'fetchGroupLabels',
'fetchCustomStageFormData',
'fetchCycleAnalyticsData',
'fetchStageData',
'setCycleAnalyticsDataEndpoint',
......@@ -92,7 +93,7 @@ export default {
'setSelectedProjects',
'setSelectedTimeframe',
'fetchStageData',
'setSelectedStageName',
'setSelectedStageId',
'hideCustomStageForm',
'showCustomStageForm',
'setDateRange',
......@@ -101,7 +102,6 @@ export default {
this.setCycleAnalyticsDataEndpoint(group.full_path);
this.setSelectedGroup(group);
this.fetchCycleAnalyticsData();
this.fetchGroupLabels(this.currentGroupPath);
},
onProjectsSelect(projects) {
const projectIds = projects.map(value => value.id);
......@@ -110,7 +110,7 @@ export default {
},
onStageSelect(stage) {
this.hideCustomStageForm();
this.setSelectedStageName(stage.name);
this.setSelectedStageId(stage.id);
this.setStageDataEndpoint(this.currentStage.slug);
this.fetchStageData(this.currentStage.name);
},
......@@ -196,24 +196,29 @@ export default {
"
/>
<div v-else-if="!errorCode">
<summary-table class="js-summary-table" :items="summary" />
<stage-table
v-if="currentStage"
class="js-stage-table"
:current-stage="currentStage"
:stages="stages"
:is-loading="isLoadingStage"
:is-empty-stage="isEmptyStage"
:is-adding-custom-stage="isAddingCustomStage"
:current-stage-events="currentStageEvents"
:custom-stage-form-events="customStageFormEvents"
:labels="labels"
:no-data-svg-path="noDataSvgPath"
:no-access-svg-path="noAccessSvgPath"
:can-edit-stages="hasCustomizableCycleAnalytics"
@selectStage="onStageSelect"
@showAddStageForm="onShowAddStageForm"
/>
<div v-if="isLoading">
<gl-loading-icon class="mt-4" size="md" />
</div>
<div v-else>
<summary-table class="js-summary-table" :items="summary" />
<stage-table
v-if="currentStage"
class="js-stage-table"
:current-stage="currentStage"
:stages="stages"
:is-loading="isLoadingStage"
:is-empty-stage="isEmptyStage"
:is-adding-custom-stage="isAddingCustomStage"
:current-stage-events="currentStageEvents"
:custom-stage-form-events="customStageFormEvents"
:labels="labels"
:no-data-svg-path="noDataSvgPath"
:no-access-svg-path="noAccessSvgPath"
:can-edit-stages="hasCustomizableCycleAnalytics"
@selectStage="onStageSelect"
@showAddStageForm="onShowAddStageForm"
/>
</div>
</div>
</div>
</div>
......
......@@ -40,13 +40,13 @@ export default {
<limit-warning :count="events.length" />
</div>
<stage-build-item
v-if="isCurrentStage(stage.name, STAGE_NAME_TEST)"
v-if="isCurrentStage(stage.slug, STAGE_NAME_TEST)"
:stage="stage"
:events="events"
:with-build-status="true"
/>
<stage-build-item
v-else-if="isCurrentStage(stage.name, STAGE_NAME_STAGING)"
v-else-if="isCurrentStage(stage.slug, STAGE_NAME_STAGING)"
:stage="stage"
:events="events"
/>
......
......@@ -17,10 +17,6 @@ export default {
default: false,
required: false,
},
isUserAllowed: {
type: Boolean,
required: true,
},
title: {
type: String,
required: true,
......@@ -41,7 +37,7 @@ export default {
return this.value && this.value.length > 0;
},
editable() {
return this.isUserAllowed && this.canEdit;
return this.canEdit;
},
},
};
......@@ -54,13 +50,8 @@ export default {
{{ title }}
</div>
<div class="stage-nav-item-cell stage-median mr-4">
<template v-if="isUserAllowed">
<span v-if="hasValue">{{ value }}</span>
<span v-else class="stage-empty">{{ __('Not enough data') }}</span>
</template>
<template v-else>
<span class="not-available">{{ __('Not available') }}</span>
</template>
<span v-if="hasValue">{{ value }}</span>
<span v-else class="stage-empty">{{ __('Not enough data') }}</span>
</div>
<template v-slot:dropdown-options>
<template v-if="isDefaultStage">
......
......@@ -71,7 +71,7 @@ export default {
},
computed: {
stageName() {
return this.currentStage ? this.currentStage.legend : __('Related Issues');
return this.currentStage ? this.currentStage.title : __('Related Issues');
},
shouldDisplayStage() {
const { currentStageEvents = [], isLoading, isEmptyStage } = this;
......@@ -138,8 +138,8 @@ export default {
:key="`ca-stage-title-${stage.title}`"
:title="stage.title"
:value="stage.value"
:is-active="!isAddingCustomStage && stage.name === currentStage.name"
:is-user-allowed="stage.isUserAllowed"
:is-active="!isAddingCustomStage && stage.id === currentStage.id"
:is-default-stage="!stage.custom"
@select="selectStage(stage)"
/>
<add-stage-button
......@@ -151,12 +151,6 @@ export default {
</nav>
<div class="section stage-events">
<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
v-else-if="isAddingCustomStage"
:events="customStageFormEvents"
......
import dateFormat from 'dateformat';
import axios from '~/lib/utils/axios_utils';
import createFlash, { hideFlash } from '~/flash';
import { __ } from '~/locale';
import Api from '~/api';
import httpStatus from '~/lib/utils/http_status';
import * as types from './mutation_types';
import { dateFormats } from '../../shared/constants';
import { nestQueryStringKeys } from '../utils';
const removeError = () => {
const flashEl = document.querySelector('.flash-alert');
......@@ -22,8 +21,8 @@ export const setStageDataEndpoint = ({ commit }, stageSlug) =>
export const setSelectedGroup = ({ commit }, group) => commit(types.SET_SELECTED_GROUP, group);
export const setSelectedProjects = ({ commit }, projectIds) =>
commit(types.SET_SELECTED_PROJECTS, projectIds);
export const setSelectedStageName = ({ commit }, stageName) =>
commit(types.SET_SELECTED_STAGE_NAME, stageName);
export const setSelectedStageId = ({ commit }, stageId) =>
commit(types.SET_SELECTED_STAGE_ID, stageId);
export const setDateRange = (
{ commit, dispatch, state },
......@@ -42,38 +41,24 @@ export const receiveStageDataSuccess = ({ commit }, data) =>
export const receiveStageDataError = ({ commit }) => {
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');
axios
.get(state.endpoints.stageData, {
params: {
'cycle_analytics[created_after]': dateFormat(state.startDate, dateFormats.isoDate),
'cycle_analytics[created_before]': dateFormat(state.endDate, dateFormats.isoDate),
'cycle_analytics[project_ids]': state.selectedProjectIds,
},
params: nestQueryStringKeys(cycleAnalyticsRequestParams, 'cycle_analytics'),
})
.then(({ data }) => dispatch('receiveStageDataSuccess', data))
.catch(error => dispatch('receiveStageDataError', error));
};
export const requestCycleAnalyticsData = ({ commit }) => commit(types.REQUEST_CYCLE_ANALYTICS_DATA);
export const receiveCycleAnalyticsDataSuccess = ({ state, commit, dispatch }, data) => {
commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS, data);
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 receiveCycleAnalyticsDataSuccess = ({ commit }) =>
commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS);
export const receiveCycleAnalyticsDataError = ({ commit }, { response }) => {
const { status } = response;
......@@ -83,21 +68,41 @@ export const receiveCycleAnalyticsDataError = ({ commit }, { response }) => {
createFlash(__('There was an error while fetching cycle analytics data.'));
};
export const fetchCycleAnalyticsData = ({ state, dispatch }) => {
dispatch('requestCycleAnalyticsData');
export const fetchCycleAnalyticsData = ({ dispatch }) => {
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, {
params: {
'cycle_analytics[created_after]': dateFormat(state.startDate, dateFormats.isoDate),
'cycle_analytics[created_before]': dateFormat(state.endDate, dateFormats.isoDate),
'cycle_analytics[project_ids]': state.selectedProjectIds,
},
params: nestQueryStringKeys(cycleAnalyticsRequestParams, 'cycle_analytics'),
})
.then(({ data }) => dispatch('receiveCycleAnalyticsDataSuccess', data))
.catch(error => dispatch('receiveCycleAnalyticsDataError', error));
.then(({ data }) => dispatch('receiveSummaryDataSuccess', data))
.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 showCustomStageForm = ({ commit }) => commit(types.SHOW_CUSTOM_STAGE_FORM);
......@@ -111,10 +116,44 @@ export const receiveGroupLabelsError = ({ commit }, error) => {
export const requestGroupLabels = ({ commit }) => commit(types.REQUEST_GROUP_LABELS);
export const fetchGroupLabels = ({ dispatch }, groupPath) => {
export const fetchGroupLabels = ({ dispatch, state }) => {
dispatch('requestGroupLabels');
const {
selectedGroup: { fullPath },
} = state;
return Api.groupLabels(groupPath)
return Api.groupLabels(fullPath)
.then(data => dispatch('receiveGroupLabelsSuccess', data))
.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 { dateFormats } from '../../shared/constants';
export const currentStage = ({ stages, selectedStageName }) =>
stages.length && selectedStageName
? stages.find(stage => stage.name === selectedStageName)
: null;
export const currentStage = ({ stages, selectedStageId }) =>
stages.length && selectedStageId ? stages.find(stage => stage.id === selectedStageId) : null;
export const defaultStage = state => (state.stages.length ? state.stages[0] : null);
export const hasNoAccessError = state => state.errorCode === httpStatus.FORBIDDEN;
export const currentGroupPath = ({ selectedGroup }) =>
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';
export const SET_SELECTED_GROUP = 'SET_SELECTED_GROUP';
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';
......@@ -21,3 +21,11 @@ export const SHOW_CUSTOM_STAGE_FORM = 'SHOW_CUSTOM_STAGE_FORM';
export const REQUEST_GROUP_LABELS = 'REQUEST_GROUP_LABELS';
export const RECEIVE_GROUP_LABELS_SUCCESS = 'RECEIVE_GROUP_LABELS_SUCCESS';
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 * as types from './mutation_types';
import { EMPTY_STAGE_TEXT } from '../constants';
import { transformRawStages } from '../utils';
export default {
[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.cycleAnalyticsStagesAndEvents = `/-/analytics/cycle_analytics/stages?group_id=${groupPath}`;
},
[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) {
state.selectedGroup = convertObjectPropsToCamelCase(group, { deep: true });
......@@ -17,8 +22,8 @@ export default {
[types.SET_SELECTED_PROJECTS](state, projectIds) {
state.selectedProjectIds = projectIds;
},
[types.SET_SELECTED_STAGE_NAME](state, stageName) {
state.selectedStageName = stageName;
[types.SET_SELECTED_STAGE_ID](state, stageId) {
state.selectedStageId = stageId;
},
[types.SET_DATE_RANGE](state, { startDate, endDate }) {
state.startDate = startDate;
......@@ -28,27 +33,7 @@ export default {
state.isLoading = true;
state.isAddingCustomStage = false;
},
[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) {
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;
}
[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state) {
state.errorCode = null;
state.isLoading = false;
},
......@@ -58,13 +43,15 @@ export default {
},
[types.REQUEST_STAGE_DATA](state) {
state.isLoadingStage = true;
state.isEmptyStage = false;
},
[types.RECEIVE_STAGE_DATA_SUCCESS](state, data = {}) {
const { events = [] } = data;
state.currentStageEvents = events.map(({ name = '', ...rest }) =>
convertObjectPropsToCamelCase({ title: name, ...rest }, { deep: true }),
);
state.isEmptyStage = state.currentStageEvents.length === 0;
state.isEmptyStage = !events.length;
state.isLoadingStage = false;
},
[types.RECEIVE_STAGE_DATA_ERROR](state) {
......@@ -86,4 +73,51 @@ export default {
[types.SHOW_CUSTOM_STAGE_FORM](state) {
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 () => ({
endpoints: {
cycleAnalyticsData: null,
stageData: null,
cycleAnalyticsStagesAndEvents: null,
summaryData: null,
},
startDate: null,
......@@ -17,7 +19,7 @@ export default () => ({
selectedGroup: null,
selectedProjectIds: [],
selectedStageName: null,
selectedStageId: null,
currentStageEvents: [],
......
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { isString } from 'underscore';
const EVENT_TYPE_LABEL = 'label';
export const isStartEvent = ev => Boolean(ev) && Boolean(ev.canBeStartEvent) && ev.canBeStartEvent;
......@@ -24,3 +28,20 @@ export const isLabelEvent = (labelEvents = [], ev = null) =>
export const getLabelEventsIdentifiers = (events = []) =>
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
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)
def select_group
dropdown = page.find('.dropdown-groups')
dropdown.click
dropdown.find('a').click
wait_for_requests
end
def select_project
......@@ -216,12 +222,7 @@ describe 'Group Cycle Analytics', :js do
dropdown.click
dropdown.find('a').click
# Make capybara wait until all the .stage-nav-item elements are rendered
# We should have NUMBER_OF_STAGES + 1 (button)
expect(page).to have_selector(
'.stage-nav-item',
count: Gitlab::Analytics::CycleAnalytics::DefaultStages.all.size + 1
)
wait_for_stages_to_load
end
context 'Add a stage button' do
......
import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import Vue from 'vue';
import httpStatusCodes from '~/lib/utils/http_status';
import store from 'ee/analytics/cycle_analytics/store';
import Component from 'ee/analytics/cycle_analytics/components/base.vue';
import { GlEmptyState, GlDaterangePicker } from '@gitlab/ui';
......@@ -41,8 +42,8 @@ function createComponent({ opts = {}, shallow = true, withStageSelected = false
...mockData.group,
});
comp.vm.$store.dispatch('receiveCycleAnalyticsDataSuccess', {
...mockData.cycleAnalyticsData,
comp.vm.$store.dispatch('receiveGroupStagesAndEventsSuccess', {
...mockData.customizableStagesAndEvents,
});
comp.vm.$store.dispatch('receiveStageDataSuccess', {
......@@ -296,6 +297,40 @@ describe('Cycle Analytics component', () => {
});
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(() => {
setFixtures('<div class="flash-container"></div>');
......@@ -310,20 +345,22 @@ describe('Cycle Analytics component', () => {
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();
mock
.onGet('/groups/foo/-/labels')
.replyOnce(200, { response: { ...mockData.groupLabels } })
.onGet('/groups/foo/-/cycle_analytics')
.replyOnce(500, { response: { status: 500 } });
mockRequestCycleAnalyticsData({
fetchSummaryData: {
status: httpStatusCodes.NOT_FOUND,
endpoint: `/groups/${groupId}/-/cycle_analytics`,
response: { response: { status: httpStatusCodes.NOT_FOUND } },
},
});
wrapper.vm.onGroupSelect(mockData.group);
return waitForPromises().then(() => {
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', () => {
it('will display an error if the fetchGroupLabels request fails', () => {
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);
......@@ -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) {
labels: groupLabels,
isLoading: false,
isEmptyStage: false,
isUserAllowed: true,
isAddingCustomStage: false,
noDataSvgPath,
noAccessSvgPath,
......@@ -159,45 +158,9 @@ describe('StageTable', () => {
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.");
});
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', () => {
......
......@@ -7,6 +7,16 @@ import { getDateInPast } from '~/lib/utils/datetime_utility';
import { DEFAULT_DAYS_IN_PAST } from 'ee/analytics/cycle_analytics/constants';
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 group = {
......@@ -17,22 +27,26 @@ export const group = {
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 = {};
// 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 planStage = getStageBySlug(dummyState.stages, 'plan');
export const reviewStage = getStageBySlug(dummyState.stages, 'review');
export const codeStage = getStageBySlug(dummyState.stages, 'code');
export const testStage = getStageBySlug(dummyState.stages, 'test');
export const stagingStage = getStageBySlug(dummyState.stages, 'staging');
export const productionStage = getStageBySlug(dummyState.stages, 'production');
export const issueStage = getStageById(dummyState.stages, 'issue');
export const planStage = getStageById(dummyState.stages, 'plan');
export const reviewStage = getStageById(dummyState.stages, 'review');
export const codeStage = getStageById(dummyState.stages, 'code');
export const testStage = getStageById(dummyState.stages, 'test');
export const stagingStage = getStageById(dummyState.stages, 'staging');
export const productionStage = getStageById(dummyState.stages, 'production');
export const allowedStages = [issueStage, planStage, codeStage];
......@@ -43,14 +57,14 @@ const deepCamelCase = obj => convertObjectPropsToCamelCase(obj, { deep: true });
const defaultStages = ['issue', 'plan', 'review', 'code', 'test', 'staging', 'production'];
const stageFixtures = defaultStages.reduce((acc, stage) => {
const { events } = getJSONFixture(`cycle_analytics/events/${stage}.json`);
const { events } = getJSONFixture(endpoints.stageEvents(stage));
return {
...acc,
[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 issueEvents = stageFixtures.issue;
......@@ -61,7 +75,7 @@ export const testEvents = stageFixtures.test;
export const stagingEvents = stageFixtures.staging;
export const productionEvents = stageFixtures.production;
const { events: rawCustomStageEvents } = getJSONFixture('analytics/cycle_analytics/stages.json');
const { events: rawCustomStageEvents } = customizableStagesAndEvents;
const camelCasedStageEvents = rawCustomStageEvents.map(deepCamelCase);
export const customStageStartEvents = camelCasedStageEvents.filter(ev => ev.canBeStartEvent);
......@@ -74,6 +88,7 @@ export const customStageStopEvents = camelCasedStageEvents.filter(ev =>
);
// 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' };
const firstAllowedStopEvent = labelStartEvent.allowedEndEvents[0];
// We need to enusre that the stop event can be applied to the start event
......
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;
const selectedProjectIds = [5, 8, 11];
describe('Cycle analytics getters', () => {
describe('with default state', () => {
beforeEach(() => {
state = {
stages: [],
selectedStageName: null,
selectedStageId: null,
};
});
......@@ -33,7 +34,7 @@ describe('Cycle analytics getters', () => {
beforeEach(() => {
state = {
stages,
selectedStageName: null,
selectedStageId: null,
};
});
......@@ -58,7 +59,7 @@ describe('Cycle analytics getters', () => {
beforeEach(() => {
state = {
stages,
selectedStageName: stages[2].name,
selectedStageId: stages[2].id,
};
});
......@@ -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 {
groupLabels,
startDate,
endDate,
customizableStagesAndEvents,
} from '../mock_data';
let state = null;
......@@ -29,15 +30,21 @@ describe('Cycle analytics mutations', () => {
});
it.each`
mutation | stateKey | value
${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false}
${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'isLoading'} | ${true}
${types.HIDE_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${false}
${types.SHOW_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${true}
${types.REQUEST_GROUP_LABELS} | ${'labels'} | ${[]}
${types.RECEIVE_GROUP_LABELS_ERROR} | ${'labels'} | ${[]}
mutation | stateKey | value
${types.HIDE_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${false}
${types.SHOW_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${true}
${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false}
${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'isLoading'} | ${true}
${types.REQUEST_GROUP_LABELS} | ${'labels'} | ${[]}
${types.RECEIVE_GROUP_LABELS_ERROR} | ${'labels'} | ${[]}
${types.RECEIVE_SUMMARY_DATA_ERROR} | ${'summary'} | ${[]}
${types.REQUEST_SUMMARY_DATA} | ${'summary'} | ${[]}
${types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR} | ${'stages'} | ${[]}
${types.REQUEST_GROUP_STAGES_AND_EVENTS} | ${'stages'} | ${[]}
${types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR} | ${'customStageFormEvents'} | ${[]}
${types.REQUEST_GROUP_STAGES_AND_EVENTS} | ${'customStageFormEvents'} | ${[]}
`('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => {
mutations[mutation](state);
......@@ -46,12 +53,12 @@ describe('Cycle analytics mutations', () => {
it.each`
mutation | payload | expectedState
${types.SET_CYCLE_ANALYTICS_DATA_ENDPOINT} | ${'cool-beans'} | ${{ endpoints: { cycleAnalyticsData: '/groups/cool-beans/-/cycle_analytics' } }}
${types.SET_STAGE_DATA_ENDPOINT} | ${'rad-stage'} | ${{ endpoints: { stageData: '/fake/api/events/rad-stage.json' } }}
${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: '/groups/rad-stage/-/cycle_analytics/events/rad-stage.json' } }}
${types.SET_SELECTED_GROUP} | ${{ fullPath: 'cool-beans' }} | ${{ selectedGroup: { fullPath: 'cool-beans' }, selectedProjectIds: [] }}
${types.SET_SELECTED_PROJECTS} | ${[606, 707, 808, 909]} | ${{ selectedProjectIds: [606, 707, 808, 909] }}
${types.SET_DATE_RANGE} | ${{ startDate, endDate }} | ${{ startDate, endDate }}
${types.SET_SELECTED_STAGE_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, payload, expectedState }) => {
......@@ -110,11 +117,18 @@ describe('Cycle analytics mutations', () => {
expect(state.errorCode).toBe(null);
expect(state.isLoading).toBe(false);
});
});
describe(`${types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS}`, () => {
describe('with data', () => {
it('will convert the stats object to stages', () => {
mutations[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, cycleAnalyticsData);
beforeEach(() => {
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(
stage => {
expect(state.stages).toContainEqual(stage);
......@@ -122,25 +136,48 @@ describe('Cycle analytics mutations', () => {
);
});
it('will set the selectedStageName to the name of the first stage', () => {
mutations[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, cycleAnalyticsData);
expect(state.selectedStageName).toEqual('issue');
it('will set the selectedStageId to the id of the first stage', () => {
expect(state.selectedStageId).toEqual('issue');
});
});
});
it('will set each summary item with a value of 0 to "-"', () => {
// { value: '-', title: 'New Issues' }, { value: '-', title: 'Deploys' }
describe(`${types.RECEIVE_SUMMARY_DATA_SUCCESS}`, () => {
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, {
...cycleAnalyticsData,
summary: [{ value: 0, title: 'New Issues' }, { value: 0, title: 'Deploys' }],
});
it('will set each summary item with a value of 0 to "-"', () => {
expect(state.summary).toEqual([
{ value: '-', title: 'New Issues' },
{ value: '-', title: 'Deploys' },
]);
});
expect(state.summary).toEqual([
{ value: '-', title: 'New Issues' },
{ value: '-', title: 'Deploys' },
]);
});
it('will set the median value for each stage', () => {
expect(state.stages).toEqual([
{ slug: 'plan', value: '6 months ago' },
{ slug: 'issue', value: '1 day ago' },
{ slug: 'test', value: null },
]);
});
});
......
......@@ -5,6 +5,7 @@ import {
eventToOption,
eventsByIdentifier,
getLabelEventsIdentifiers,
nestQueryStringKeys,
} from 'ee/analytics/cycle_analytics/utils';
import {
customStageEvents as events,
......@@ -103,4 +104,30 @@ describe('Cycle analytics utils', () => {
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 ""
msgid "There was an error fetching configuration for charts"
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"
msgstr ""
......@@ -17009,6 +17015,9 @@ msgstr ""
msgid "There was an error while fetching cycle analytics data."
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."
msgstr ""
......
......@@ -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', () => {
it('converts Sentence Case to Sentence case', () => {
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