Commit b154d5e0 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch...

Merge branch '34751-fetch-cycle-analytics-stage-and-median-data-from-dedicated-endpoints' into 'master'

"New cycle analytics stage and median data endpoints"

Closes #34751

See merge request gitlab-org/gitlab!19278
parents 56c2e005 9fcd04f8
...@@ -69,6 +69,7 @@ export default { ...@@ -69,6 +69,7 @@ export default {
'startDate', 'startDate',
'endDate', 'endDate',
'tasksByType', 'tasksByType',
'medians',
]), ]),
...mapGetters(['hasNoAccessError', 'currentGroupPath', 'durationChartPlottableData']), ...mapGetters(['hasNoAccessError', 'currentGroupPath', 'durationChartPlottableData']),
shouldRenderEmptyState() { shouldRenderEmptyState() {
...@@ -242,6 +243,7 @@ export default { ...@@ -242,6 +243,7 @@ export default {
class="js-stage-table" class="js-stage-table"
:current-stage="selectedStage" :current-stage="selectedStage"
:stages="stages" :stages="stages"
:medians="medians"
:is-loading="isLoadingStage" :is-loading="isLoadingStage"
:is-empty-stage="isEmptyStage" :is-empty-stage="isEmptyStage"
:is-saving-custom-stage="isSavingCustomStage" :is-saving-custom-stage="isSavingCustomStage"
......
...@@ -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.slug, STAGE_NAME_TEST)" v-if="isCurrentStage(stage.title, 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.slug, STAGE_NAME_STAGING)" v-else-if="isCurrentStage(stage.title, STAGE_NAME_STAGING)"
:stage="stage" :stage="stage"
:events="events" :events="events"
/> />
......
<script> <script>
import { GlButton } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import StageCardListItem from './stage_card_list_item.vue'; import { approximateDuration } from '~/lib/utils/datetime_utility';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import StageCardListItem from './stage_card_list_item.vue';
export default { export default {
name: 'StageNavItem', name: 'StageNavItem',
...@@ -26,8 +27,8 @@ export default { ...@@ -26,8 +27,8 @@ export default {
required: true, required: true,
}, },
value: { value: {
type: String, type: Number,
default: '', default: 0,
required: false, required: false,
}, },
canEdit: { canEdit: {
...@@ -43,7 +44,10 @@ export default { ...@@ -43,7 +44,10 @@ export default {
}, },
computed: { computed: {
hasValue() { hasValue() {
return this.value && this.value.length > 0; return this.value;
},
median() {
return approximateDuration(this.value);
}, },
editable() { editable() {
return this.canEdit; return this.canEdit;
...@@ -74,7 +78,7 @@ export default { ...@@ -74,7 +78,7 @@ 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">
<span v-if="hasValue">{{ value }}</span> <span v-if="hasValue">{{ median }}</span>
<span v-else class="stage-empty">{{ __('Not enough data') }}</span> <span v-else class="stage-empty">{{ __('Not enough data') }}</span>
</div> </div>
<div v-show="canEdit && isHover" ref="dropdown" class="dropdown"> <div v-show="canEdit && isHover" ref="dropdown" class="dropdown">
......
...@@ -27,6 +27,10 @@ export default { ...@@ -27,6 +27,10 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
medians: {
type: Object,
required: true,
},
currentStage: { currentStage: {
type: Object, type: Object,
required: true, required: true,
...@@ -119,6 +123,11 @@ export default { ...@@ -119,6 +123,11 @@ export default {
return this.isEditingCustomStage ? this.currentStage : {}; return this.isEditingCustomStage ? this.currentStage : {};
}, },
}, },
methods: {
medianValue(id) {
return this.medians[id] ? this.medians[id] : null;
},
},
STAGE_ACTIONS, STAGE_ACTIONS,
}; };
</script> </script>
...@@ -146,7 +155,7 @@ export default { ...@@ -146,7 +155,7 @@ export default {
v-for="stage in stages" v-for="stage in stages"
:key="`ca-stage-title-${stage.title}`" :key="`ca-stage-title-${stage.title}`"
:title="stage.title" :title="stage.title"
:value="stage.value" :value="medianValue(stage.id)"
:is-active="!isCreatingCustomStage && stage.id === currentStage.id" :is-active="!isCreatingCustomStage && stage.id === currentStage.id"
:can-edit="canEditStages" :can-edit="canEditStages"
:is-default-stage="!stage.custom" :is-default-stage="!stage.custom"
......
...@@ -29,8 +29,9 @@ export const setDateRange = ({ commit, dispatch }, { skipFetch = false, startDat ...@@ -29,8 +29,9 @@ export const setDateRange = ({ commit, dispatch }, { skipFetch = false, startDat
}; };
export const requestStageData = ({ commit }) => commit(types.REQUEST_STAGE_DATA); export const requestStageData = ({ commit }) => commit(types.REQUEST_STAGE_DATA);
export const receiveStageDataSuccess = ({ commit }, data) => export const receiveStageDataSuccess = ({ commit }, data) => {
commit(types.RECEIVE_STAGE_DATA_SUCCESS, data); commit(types.RECEIVE_STAGE_DATA_SUCCESS, data);
};
export const receiveStageDataError = ({ commit }) => { export const receiveStageDataError = ({ commit }) => {
commit(types.RECEIVE_STAGE_DATA_ERROR); commit(types.RECEIVE_STAGE_DATA_ERROR);
...@@ -45,15 +46,48 @@ export const fetchStageData = ({ state, dispatch, getters }, slug) => { ...@@ -45,15 +46,48 @@ export const fetchStageData = ({ state, dispatch, getters }, slug) => {
dispatch('requestStageData'); dispatch('requestStageData');
return Api.cycleAnalyticsStageEvents( return Api.cycleAnalyticsStageEvents(fullPath, slug, cycleAnalyticsRequestParams)
fullPath,
slug,
nestQueryStringKeys(cycleAnalyticsRequestParams, 'cycle_analytics'),
)
.then(({ data }) => dispatch('receiveStageDataSuccess', data)) .then(({ data }) => dispatch('receiveStageDataSuccess', data))
.catch(error => dispatch('receiveStageDataError', error)); .catch(error => dispatch('receiveStageDataError', error));
}; };
export const requestStageMedianValues = ({ commit }) => commit(types.REQUEST_STAGE_MEDIANS);
export const receiveStageMedianValuesSuccess = ({ commit }, data) => {
commit(types.RECEIVE_STAGE_MEDIANS_SUCCESS, data);
};
export const receiveStageMedianValuesError = ({ commit }) => {
commit(types.RECEIVE_STAGE_MEDIANS_ERROR);
createFlash(__('There was an error fetching median data for stages'));
};
const fetchStageMedian = (currentGroupPath, stageId, params) =>
Api.cycleAnalyticsStageMedian(currentGroupPath, stageId, params).then(({ data }) => ({
id: stageId,
...data,
}));
export const fetchStageMedianValues = ({ state, dispatch, getters }) => {
const {
currentGroupPath,
cycleAnalyticsRequestParams: { created_after, created_before },
} = getters;
const { stages } = state;
const params = {
group_id: currentGroupPath,
created_after,
created_before,
};
dispatch('requestStageMedianValues');
const stageIds = stages.map(s => s.slug);
return Promise.all(stageIds.map(stageId => fetchStageMedian(currentGroupPath, stageId, params)))
.then(data => dispatch('receiveStageMedianValuesSuccess', data))
.catch(err => dispatch('receiveStageMedianValuesError', err));
};
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 }) => { export const receiveCycleAnalyticsDataSuccess = ({ state, commit, dispatch }) => {
commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS); commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS);
...@@ -76,9 +110,11 @@ export const receiveCycleAnalyticsDataError = ({ commit }, { response }) => { ...@@ -76,9 +110,11 @@ export const receiveCycleAnalyticsDataError = ({ commit }, { response }) => {
export const fetchCycleAnalyticsData = ({ dispatch }) => { export const fetchCycleAnalyticsData = ({ dispatch }) => {
removeError(); removeError();
return dispatch('requestCycleAnalyticsData') dispatch('requestCycleAnalyticsData');
return Promise.resolve()
.then(() => dispatch('fetchGroupLabels')) .then(() => dispatch('fetchGroupLabels'))
.then(() => dispatch('fetchGroupStagesAndEvents')) .then(() => dispatch('fetchGroupStagesAndEvents'))
.then(() => dispatch('fetchStageMedianValues'))
.then(() => dispatch('fetchSummaryData')) .then(() => dispatch('fetchSummaryData'))
.then(() => dispatch('receiveCycleAnalyticsDataSuccess')) .then(() => dispatch('receiveCycleAnalyticsDataSuccess'))
.catch(error => dispatch('receiveCycleAnalyticsDataError', error)); .catch(error => dispatch('receiveCycleAnalyticsDataError', error));
...@@ -216,7 +252,7 @@ export const receiveTasksByTypeDataSuccess = ({ commit }, data) => ...@@ -216,7 +252,7 @@ export const receiveTasksByTypeDataSuccess = ({ commit }, data) =>
export const receiveTasksByTypeDataError = ({ commit }, error) => { export const receiveTasksByTypeDataError = ({ commit }, error) => {
commit(types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR, error); commit(types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR, error);
createFlash(__('There was an error fetching data for the chart')); createFlash(__('There was an error fetching data for the tasks by type chart'));
}; };
export const requestTasksByTypeData = ({ commit }) => commit(types.REQUEST_TASKS_BY_TYPE_DATA); export const requestTasksByTypeData = ({ commit }) => commit(types.REQUEST_TASKS_BY_TYPE_DATA);
......
...@@ -15,6 +15,10 @@ export const REQUEST_STAGE_DATA = 'REQUEST_STAGE_DATA'; ...@@ -15,6 +15,10 @@ export const REQUEST_STAGE_DATA = 'REQUEST_STAGE_DATA';
export const RECEIVE_STAGE_DATA_SUCCESS = 'RECEIVE_STAGE_DATA_SUCCESS'; export const RECEIVE_STAGE_DATA_SUCCESS = 'RECEIVE_STAGE_DATA_SUCCESS';
export const RECEIVE_STAGE_DATA_ERROR = 'RECEIVE_STAGE_DATA_ERROR'; export const RECEIVE_STAGE_DATA_ERROR = 'RECEIVE_STAGE_DATA_ERROR';
export const REQUEST_STAGE_MEDIANS = 'REQUEST_STAGE_MEDIANS';
export const RECEIVE_STAGE_MEDIANS_SUCCESS = 'RECEIVE_STAGE_MEDIANS_SUCCESS';
export const RECEIVE_STAGE_MEDIANS_ERROR = 'RECEIVE_STAGE_MEDIANS_ERROR';
export const HIDE_CUSTOM_STAGE_FORM = 'HIDE_CUSTOM_STAGE_FORM'; export const HIDE_CUSTOM_STAGE_FORM = 'HIDE_CUSTOM_STAGE_FORM';
export const SHOW_CUSTOM_STAGE_FORM = 'SHOW_CUSTOM_STAGE_FORM'; export const SHOW_CUSTOM_STAGE_FORM = 'SHOW_CUSTOM_STAGE_FORM';
export const EDIT_CUSTOM_STAGE = 'EDIT_CUSTOM_STAGE'; export const EDIT_CUSTOM_STAGE = 'EDIT_CUSTOM_STAGE';
......
...@@ -40,11 +40,9 @@ export default { ...@@ -40,11 +40,9 @@ export default {
state.isLoadingStage = true; state.isLoadingStage = true;
state.isEmptyStage = false; state.isEmptyStage = false;
}, },
[types.RECEIVE_STAGE_DATA_SUCCESS](state, data = {}) { [types.RECEIVE_STAGE_DATA_SUCCESS](state, events = []) {
const { events = [] } = data; state.currentStageEvents = events.map(fields =>
convertObjectPropsToCamelCase(fields, { deep: true }),
state.currentStageEvents = events.map(({ name = '', ...rest }) =>
convertObjectPropsToCamelCase({ title: name, ...rest }, { deep: true }),
); );
state.isEmptyStage = !events.length; state.isEmptyStage = !events.length;
state.isLoadingStage = false; state.isLoadingStage = false;
...@@ -60,6 +58,21 @@ export default { ...@@ -60,6 +58,21 @@ export default {
labelIds: [], labelIds: [],
}; };
}, },
[types.REQUEST_STAGE_MEDIANS](state) {
state.medians = {};
},
[types.RECEIVE_STAGE_MEDIANS_SUCCESS](state, medians = []) {
state.medians = medians.reduce(
(acc, { id, value }) => ({
...acc,
[id]: value,
}),
{},
);
},
[types.RECEIVE_STAGE_MEDIANS_ERROR](state) {
state.medians = {};
},
[types.RECEIVE_GROUP_LABELS_SUCCESS](state, data = []) { [types.RECEIVE_GROUP_LABELS_SUCCESS](state, data = []) {
const { tasksByType } = state; const { tasksByType } = state;
state.labels = data.map(convertObjectPropsToCamelCase); state.labels = data.map(convertObjectPropsToCamelCase);
...@@ -98,24 +111,11 @@ export default { ...@@ -98,24 +111,11 @@ export default {
state.summary = []; state.summary = [];
}, },
[types.RECEIVE_SUMMARY_DATA_SUCCESS](state, data) { [types.RECEIVE_SUMMARY_DATA_SUCCESS](state, data) {
const { stages } = state; const { summary } = data;
const { summary, stats } = data;
state.summary = summary.map(item => ({ state.summary = summary.map(item => ({
...item, ...item,
value: item.value || '-', 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) { [types.REQUEST_GROUP_STAGES_AND_EVENTS](state) {
state.stages = []; state.stages = [];
......
...@@ -27,6 +27,7 @@ export default () => ({ ...@@ -27,6 +27,7 @@ export default () => ({
stages: [], stages: [],
summary: [], summary: [],
labels: [], labels: [],
medians: {},
customStageFormEvents: [], customStageFormEvents: [],
tasksByType: { tasksByType: {
......
import { isString } from 'underscore'; import { isString, isNumber } from 'underscore';
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { convertToSnakeCase } from '~/lib/utils/text_utility'; import { convertToSnakeCase } from '~/lib/utils/text_utility';
...@@ -43,18 +43,19 @@ export const getLabelEventsIdentifiers = (events = []) => ...@@ -43,18 +43,19 @@ export const getLabelEventsIdentifiers = (events = []) =>
* default stages get persisted to storage and will have a numeric id. The new numeric * default stages get persisted to storage and will have a numeric id. The new numeric
* id should then be used to access stage data * id should then be used to access stage data
* *
* This will be fixed in https://gitlab.com/gitlab-org/gitlab/merge_requests/19278
*/ */
export const isPersistedStage = ({ custom, id }) => custom || isNumber(id);
export const transformRawStages = (stages = []) => export const transformRawStages = (stages = []) =>
stages stages
.map(({ id, title, custom = false, ...rest }) => ({ .map(({ id, title, name = '', custom = false, ...rest }) => ({
...convertObjectPropsToCamelCase(rest, { deep: true }), ...convertObjectPropsToCamelCase(rest, { deep: true }),
id, id,
title, title,
slug: custom ? id : convertToSnakeCase(title),
custom, custom,
name: title, // editing a stage takes 'name' as a parameter, but the api returns title slug: isPersistedStage({ custom, id }) ? id : convertToSnakeCase(title),
// the name field is used to create a stage, but the get request returns title
name: name.length ? name : title,
})) }))
.sort((a, b) => a.id > b.id); .sort((a, b) => a.id > b.id);
......
...@@ -18,7 +18,8 @@ export default { ...@@ -18,7 +18,8 @@ export default {
cycleAnalyticsTasksByTypePath: '/-/analytics/type_of_work/tasks_by_type', cycleAnalyticsTasksByTypePath: '/-/analytics/type_of_work/tasks_by_type',
cycleAnalyticsSummaryDataPath: '/groups/:group_id/-/cycle_analytics', cycleAnalyticsSummaryDataPath: '/groups/:group_id/-/cycle_analytics',
cycleAnalyticsGroupStagesAndEventsPath: '/-/analytics/cycle_analytics/stages', cycleAnalyticsGroupStagesAndEventsPath: '/-/analytics/cycle_analytics/stages',
cycleAnalyticsStageEventsPath: '/groups/:group_id/-/cycle_analytics/events/:stage_id.json', cycleAnalyticsStageEventsPath: '/-/analytics/cycle_analytics/stages/:stage_id/records',
cycleAnalyticsStageMedianPath: '/-/analytics/cycle_analytics/stages/:stage_id/median',
cycleAnalyticsStagePath: '/-/analytics/cycle_analytics/stages/:stage_id', cycleAnalyticsStagePath: '/-/analytics/cycle_analytics/stages/:stage_id',
cycleAnalyticsDurationChartPath: '/-/analytics/cycle_analytics/stages/:stage_id/duration_chart', cycleAnalyticsDurationChartPath: '/-/analytics/cycle_analytics/stages/:stage_id/duration_chart',
...@@ -158,11 +159,13 @@ export default { ...@@ -158,11 +159,13 @@ export default {
}, },
cycleAnalyticsStageEvents(groupId, stageId, params = {}) { cycleAnalyticsStageEvents(groupId, stageId, params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsStageEventsPath) const url = Api.buildUrl(this.cycleAnalyticsStageEventsPath).replace(':stage_id', stageId);
.replace(':group_id', groupId) return axios.get(url, { params: { ...params, group_id: groupId } });
.replace(':stage_id', stageId); },
return axios.get(url, { params }); cycleAnalyticsStageMedian(groupId, stageId, params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsStageMedianPath).replace(':stage_id', stageId);
return axios.get(url, { params: { ...params, group_id: groupId } });
}, },
cycleAnalyticsCreateStage(groupId, data) { cycleAnalyticsCreateStage(groupId, data) {
......
...@@ -167,11 +167,11 @@ describe 'Group Cycle Analytics', :js do ...@@ -167,11 +167,11 @@ describe 'Group Cycle Analytics', :js do
dummy_stages = [ dummy_stages = [
{ title: "Issue", description: "Time before an issue gets scheduled", events_count: 1, median: "5 days" }, { title: "Issue", description: "Time before an issue gets scheduled", events_count: 1, median: "5 days" },
{ title: "Plan", description: "Time before an issue starts implementation", events_count: 1, median: "Not enough data" }, { title: "Plan", description: "Time before an issue starts implementation", events_count: 0, median: "Not enough data" },
{ title: "Code", description: "Time until first merge request", events_count: 1, median: "less than a minute" }, { title: "Code", description: "Time until first merge request", events_count: 0, median: "Not enough data" },
{ title: "Test", description: "Total test time for all commits/merges", events_count: 1, median: "Not enough data" }, { title: "Test", description: "Total test time for all commits/merges", events_count: 0, median: "Not enough data" },
{ title: "Review", description: "Time between merge request creation and merge/close", events_count: 1, median: "less than a minute" }, { title: "Review", description: "Time between merge request creation and merge/close", events_count: 0, median: "Not enough data" },
{ title: "Staging", description: "From merge request merge until deploy to production", events_count: 1, median: "less than a minute" }, { title: "Staging", description: "From merge request merge until deploy to production", events_count: 0, median: "Not enough data" },
{ title: "Production", description: "From issue creation until deploy to production", events_count: 1, median: "5 days" } { title: "Production", description: "From issue creation until deploy to production", events_count: 1, median: "5 days" }
] ]
...@@ -187,9 +187,13 @@ describe 'Group Cycle Analytics', :js do ...@@ -187,9 +187,13 @@ describe 'Group Cycle Analytics', :js do
dummy_stages.each do |stage| dummy_stages.each do |stage|
select_stage(stage[:title]) select_stage(stage[:title])
if stage[:events_count] == 0
expect(page).not_to have_selector('.stage-events .events-description')
else
expect(page.find('.stage-events .events-description').text).to have_text(stage[:description]) expect(page.find('.stage-events .events-description').text).to have_text(stage[:description])
end end
end end
end
it 'each stage with events will display the stage events list when selected', :sidekiq_might_not_need_inline do it 'each stage with events will display the stage events list when selected', :sidekiq_might_not_need_inline do
dummy_stages.each do |stage| dummy_stages.each do |stage|
......
...@@ -6,10 +6,10 @@ Array [ ...@@ -6,10 +6,10 @@ Array [
"custom": false, "custom": false,
"description": "Time before an issue gets scheduled", "description": "Time before an issue gets scheduled",
"hidden": false, "hidden": false,
"id": "issue", "id": 1,
"legend": "", "legend": "",
"name": "Issue", "name": "Issue",
"slug": "issue", "slug": 1,
"title": "Issue", "title": "Issue",
}, },
Object { Object {
......
...@@ -6,6 +6,10 @@ exports[`CustomStageForm Editing a custom stage isSavingCustomStage=true display ...@@ -6,6 +6,10 @@ exports[`CustomStageForm Editing a custom stage isSavingCustomStage=true display
</button>" </button>"
`; `;
exports[`CustomStageForm Empty form Start event with events does not select events with canBeStartEvent=false for the start events dropdown 1`] = `"<select name=\\"custom-stage-start-event\\" required=\\"required\\" aria-required=\\"true\\" class=\\"gl-form-select custom-select\\" id=\\"__BVID__122\\"><option value=\\"\\">Select start event</option><option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option><option value=\\"merge_request_created\\">Merge request created</option><option value=\\"merge_request_first_deployed_to_production\\">Merge request first deployed to production</option><option value=\\"merge_request_last_build_finished\\">Merge request last build finish time</option><option value=\\"merge_request_last_build_started\\">Merge request last build start time</option><option value=\\"merge_request_merged\\">Merge request merged</option><option value=\\"code_stage_start\\">Issue first mentioned in a commit</option><option value=\\"plan_stage_start\\">Issue first associated with a milestone or issue first added to a board</option><option value=\\"issue_closed\\">Issue closed</option><option value=\\"issue_first_added_to_board\\">Issue first added to a board</option><option value=\\"issue_first_associated_with_milestone\\">Issue first associated with a milestone</option><option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option><option value=\\"issue_label_added\\">Issue label was added</option><option value=\\"issue_label_removed\\">Issue label was removed</option><option value=\\"merge_request_closed\\">Merge request closed</option><option value=\\"merge_request_label_added\\">Merge Request label was added</option><option value=\\"merge_request_label_removed\\">Merge Request label was removed</option><option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option><option value=\\"merge_request_created\\">Merge request created</option><option value=\\"merge_request_first_deployed_to_production\\">Merge request first deployed to production</option><option value=\\"merge_request_last_build_finished\\">Merge request last build finish time</option><option value=\\"merge_request_last_build_started\\">Merge request last build start time</option><option value=\\"merge_request_merged\\">Merge request merged</option><option value=\\"issue_closed\\">Issue closed</option><option value=\\"issue_first_added_to_board\\">Issue first added to a board</option><option value=\\"issue_first_associated_with_milestone\\">Issue first associated with a milestone</option><option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option><option value=\\"issue_label_added\\">Issue label was added</option><option value=\\"issue_label_removed\\">Issue label was removed</option><option value=\\"merge_request_closed\\">Merge request closed</option><option value=\\"merge_request_label_added\\">Merge Request label was added</option><option value=\\"merge_request_label_removed\\">Merge Request label was removed</option><option value=\\"issue_created\\">Issue created</option></select>"`;
exports[`CustomStageForm Empty form Start event with events selects events with canBeStartEvent=true for the start events dropdown 1`] = `"<select name=\\"custom-stage-start-event\\" required=\\"required\\" aria-required=\\"true\\" class=\\"gl-form-select custom-select\\" id=\\"__BVID__94\\"><option value=\\"\\">Select start event</option><option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option><option value=\\"merge_request_created\\">Merge request created</option><option value=\\"merge_request_first_deployed_to_production\\">Merge request first deployed to production</option><option value=\\"merge_request_last_build_finished\\">Merge request last build finish time</option><option value=\\"merge_request_last_build_started\\">Merge request last build start time</option><option value=\\"merge_request_merged\\">Merge request merged</option><option value=\\"code_stage_start\\">Issue first mentioned in a commit</option><option value=\\"plan_stage_start\\">Issue first associated with a milestone or issue first added to a board</option><option value=\\"issue_closed\\">Issue closed</option><option value=\\"issue_first_added_to_board\\">Issue first added to a board</option><option value=\\"issue_first_associated_with_milestone\\">Issue first associated with a milestone</option><option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option><option value=\\"issue_label_added\\">Issue label was added</option><option value=\\"issue_label_removed\\">Issue label was removed</option><option value=\\"merge_request_closed\\">Merge request closed</option><option value=\\"merge_request_label_added\\">Merge Request label was added</option><option value=\\"merge_request_label_removed\\">Merge Request label was removed</option><option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option><option value=\\"merge_request_created\\">Merge request created</option><option value=\\"merge_request_first_deployed_to_production\\">Merge request first deployed to production</option><option value=\\"merge_request_last_build_finished\\">Merge request last build finish time</option><option value=\\"merge_request_last_build_started\\">Merge request last build start time</option><option value=\\"merge_request_merged\\">Merge request merged</option><option value=\\"issue_closed\\">Issue closed</option><option value=\\"issue_first_added_to_board\\">Issue first added to a board</option><option value=\\"issue_first_associated_with_milestone\\">Issue first associated with a milestone</option><option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option><option value=\\"issue_label_added\\">Issue label was added</option><option value=\\"issue_label_removed\\">Issue label was removed</option><option value=\\"merge_request_closed\\">Merge request closed</option><option value=\\"merge_request_label_added\\">Merge Request label was added</option><option value=\\"merge_request_label_removed\\">Merge Request label was removed</option><option value=\\"issue_created\\">Issue created</option></select>"`;
exports[`CustomStageForm Empty form isSavingCustomStage=true displays a loading icon 1`] = ` exports[`CustomStageForm Empty form isSavingCustomStage=true displays a loading icon 1`] = `
"<button disabled=\\"disabled\\" type=\\"button\\" class=\\"js-save-stage btn btn-success\\"><span class=\\"gl-spinner-container\\"><span aria-label=\\"Loading\\" aria-hidden=\\"true\\" class=\\"gl-spinner gl-spinner-orange gl-spinner-sm\\"></span></span> "<button disabled=\\"disabled\\" type=\\"button\\" class=\\"js-save-stage btn btn-success\\"><span class=\\"gl-spinner-container\\"><span aria-label=\\"Loading\\" aria-hidden=\\"true\\" class=\\"gl-spinner gl-spinner-orange gl-spinner-sm\\"></span></span>
Add stage Add stage
......
...@@ -61,9 +61,7 @@ function createComponent({ ...@@ -61,9 +61,7 @@ function createComponent({
...mockData.customizableStagesAndEvents, ...mockData.customizableStagesAndEvents,
}); });
comp.vm.$store.dispatch('receiveStageDataSuccess', { comp.vm.$store.dispatch('receiveStageDataSuccess', mockData.issueEvents);
events: mockData.issueEvents,
});
} }
return comp; return comp;
} }
...@@ -355,7 +353,12 @@ describe('Cycle Analytics component', () => { ...@@ -355,7 +353,12 @@ describe('Cycle Analytics component', () => {
describe('with failed requests while loading', () => { describe('with failed requests while loading', () => {
const { full_path: groupId } = mockData.group; const { full_path: groupId } = mockData.group;
function mockRequestCycleAnalyticsData(overrides = {}, includeDurationDataRequests = true) { function mockRequestCycleAnalyticsData({
overrides = {},
mockFetchStageData = true,
mockFetchStageMedian = true,
mockFetchDurationData = true,
}) {
const defaultStatus = 200; const defaultStatus = 200;
const defaultRequests = { const defaultRequests = {
fetchSummaryData: { fetchSummaryData: {
...@@ -373,12 +376,6 @@ describe('Cycle Analytics component', () => { ...@@ -373,12 +376,6 @@ describe('Cycle Analytics component', () => {
endpoint: `/groups/${groupId}/-/labels`, endpoint: `/groups/${groupId}/-/labels`,
response: [...mockData.groupLabels], response: [...mockData.groupLabels],
}, },
fetchStageData: {
status: defaultStatus,
// default first stage is issue
endpoint: '/groups/foo/-/cycle_analytics/events/issue.json',
response: [...mockData.issueEvents],
},
fetchTasksByTypeData: { fetchTasksByTypeData: {
status: defaultStatus, status: defaultStatus,
endpoint: '/-/analytics/type_of_work/tasks_by_type', endpoint: '/-/analytics/type_of_work/tasks_by_type',
...@@ -387,12 +384,22 @@ describe('Cycle Analytics component', () => { ...@@ -387,12 +384,22 @@ describe('Cycle Analytics component', () => {
...overrides, ...overrides,
}; };
if (includeDurationDataRequests) { if (mockFetchDurationData) {
mockData.defaultStages.forEach(stage => {
mock mock
.onGet(`${baseStagesEndpoint}/${stage}/duration_chart`) .onGet(/analytics\/cycle_analytics\/stages\/\d+\/duration_chart/)
.replyOnce(defaultStatus, [...mockData.rawDurationData]); .reply(defaultStatus, [...mockData.rawDurationData]);
}); }
if (mockFetchStageMedian) {
mock
.onGet(/analytics\/cycle_analytics\/stages\/\d+\/median/)
.reply(defaultStatus, { value: null });
}
if (mockFetchStageData) {
mock
.onGet(/analytics\/cycle_analytics\/stages\/\d+\/records/)
.reply(defaultStatus, mockData.issueEvents);
} }
Object.values(defaultRequests).forEach(({ endpoint, status, response }) => { Object.values(defaultRequests).forEach(({ endpoint, status, response }) => {
...@@ -425,11 +432,13 @@ describe('Cycle Analytics component', () => { ...@@ -425,11 +432,13 @@ describe('Cycle Analytics component', () => {
expect(findFlashError()).toBeNull(); expect(findFlashError()).toBeNull();
mockRequestCycleAnalyticsData({ mockRequestCycleAnalyticsData({
overrides: {
fetchSummaryData: { fetchSummaryData: {
status: httpStatusCodes.NOT_FOUND, status: httpStatusCodes.NOT_FOUND,
endpoint: `/groups/${groupId}/-/cycle_analytics`, endpoint: `/groups/${groupId}/-/cycle_analytics`,
response: { response: { status: httpStatusCodes.NOT_FOUND } }, response: { response: { status: httpStatusCodes.NOT_FOUND } },
}, },
},
}); });
return selectGroupAndFindError( return selectGroupAndFindError(
...@@ -441,10 +450,12 @@ describe('Cycle Analytics component', () => { ...@@ -441,10 +450,12 @@ describe('Cycle Analytics component', () => {
expect(findFlashError()).toBeNull(); expect(findFlashError()).toBeNull();
mockRequestCycleAnalyticsData({ mockRequestCycleAnalyticsData({
overrides: {
fetchGroupLabels: { fetchGroupLabels: {
status: httpStatusCodes.NOT_FOUND, status: httpStatusCodes.NOT_FOUND,
response: { response: { status: httpStatusCodes.NOT_FOUND } }, response: { response: { status: httpStatusCodes.NOT_FOUND } },
}, },
},
}); });
return selectGroupAndFindError( return selectGroupAndFindError(
...@@ -456,11 +467,13 @@ describe('Cycle Analytics component', () => { ...@@ -456,11 +467,13 @@ describe('Cycle Analytics component', () => {
expect(findFlashError()).toBeNull(); expect(findFlashError()).toBeNull();
mockRequestCycleAnalyticsData({ mockRequestCycleAnalyticsData({
overrides: {
fetchGroupStagesAndEvents: { fetchGroupStagesAndEvents: {
endPoint: '/-/analytics/cycle_analytics/stages', endPoint: '/-/analytics/cycle_analytics/stages',
status: httpStatusCodes.NOT_FOUND, status: httpStatusCodes.NOT_FOUND,
response: { response: { status: httpStatusCodes.NOT_FOUND } }, response: { response: { status: httpStatusCodes.NOT_FOUND } },
}, },
},
}); });
return selectGroupAndFindError('There was an error fetching cycle analytics stages.'); return selectGroupAndFindError('There was an error fetching cycle analytics stages.');
...@@ -470,11 +483,7 @@ describe('Cycle Analytics component', () => { ...@@ -470,11 +483,7 @@ describe('Cycle Analytics component', () => {
expect(findFlashError()).toBeNull(); expect(findFlashError()).toBeNull();
mockRequestCycleAnalyticsData({ mockRequestCycleAnalyticsData({
fetchStageData: { mockFetchStageData: false,
endPoint: `/groups/${groupId}/-/cycle_analytics/events/issue.json`,
status: httpStatusCodes.NOT_FOUND,
response: { response: { status: httpStatusCodes.NOT_FOUND } },
},
}); });
return selectGroupAndFindError('There was an error fetching data for the selected stage'); return selectGroupAndFindError('There was an error fetching data for the selected stage');
...@@ -484,27 +493,41 @@ describe('Cycle Analytics component', () => { ...@@ -484,27 +493,41 @@ describe('Cycle Analytics component', () => {
expect(findFlashError()).toBeNull(); expect(findFlashError()).toBeNull();
mockRequestCycleAnalyticsData({ mockRequestCycleAnalyticsData({
overrides: {
fetchTasksByTypeData: { fetchTasksByTypeData: {
endPoint: '/-/analytics/type_of_work/tasks_by_type', endPoint: '/-/analytics/type_of_work/tasks_by_type',
status: httpStatusCodes.BAD_REQUEST, status: httpStatusCodes.BAD_REQUEST,
response: { response: { status: httpStatusCodes.BAD_REQUEST } }, response: { response: { status: httpStatusCodes.BAD_REQUEST } },
}, },
},
}); });
return selectGroupAndFindError('There was an error fetching data for the chart'); return selectGroupAndFindError(
'There was an error fetching data for the tasks by type chart',
);
}); });
it('will display an error if the fetchDurationData request fails', () => { it('will display an error if the fetchDurationData request fails', () => {
expect(findFlashError()).toBeNull(); expect(findFlashError()).toBeNull();
mockRequestCycleAnalyticsData({}, false); mockRequestCycleAnalyticsData({
mockFetchDurationData: false,
});
wrapper.vm.onGroupSelect(mockData.group);
mockData.defaultStages.forEach(stage => { return waitForPromises().catch(() => {
mock expect(findFlashError().innerText.trim()).toEqual(
.onGet(`${baseStagesEndpoint}/${stage}/duration_chart`) 'There was an error while fetching cycle analytics duration data.',
.replyOnce(httpStatusCodes.NOT_FOUND, { );
response: { status: httpStatusCodes.NOT_FOUND }, });
}); });
it('will display an error if the fetchStageMedian request fails', () => {
expect(findFlashError()).toBeNull();
mockRequestCycleAnalyticsData({
mockFetchStageMedian: false,
}); });
wrapper.vm.onGroupSelect(mockData.group); wrapper.vm.onGroupSelect(mockData.group);
......
...@@ -97,9 +97,17 @@ describe('CustomStageForm', () => { ...@@ -97,9 +97,17 @@ describe('CustomStageForm', () => {
it('selects events with canBeStartEvent=true for the start events dropdown', () => { it('selects events with canBeStartEvent=true for the start events dropdown', () => {
const select = wrapper.find(sel.startEvent); const select = wrapper.find(sel.startEvent);
expect(select.html()).toMatchSnapshot();
});
it('does not select events with canBeStartEvent=false for the start events dropdown', () => {
const select = wrapper.find(sel.startEvent);
expect(select.html()).toMatchSnapshot();
startEvents.forEach(ev => { stopEvents
expect(select.html()).toHaveHtml( .filter(ev => !ev.canBeStartEvent)
.forEach(ev => {
expect(select.html()).not.toHaveHtml(
`<option value="${ev.identifier}">${ev.name}</option>`, `<option value="${ev.identifier}">${ev.name}</option>`,
); );
}); });
......
import { shallowMount, mount } from '@vue/test-utils'; import { shallowMount, mount } from '@vue/test-utils';
import StageBuildItem from 'ee/analytics/cycle_analytics/components/stage_build_item.vue'; import StageBuildItem from 'ee/analytics/cycle_analytics/components/stage_build_item.vue';
import { renderTotalTime } from '../helpers'; import { renderTotalTime } from '../helpers';
import { testStage as stage, testEvents as events } from '../mock_data'; import { stagingStage as stage, stagingEvents as events } from '../mock_data';
function createComponent(props = {}, shallow = true) { function createComponent(props = {}, shallow = true) {
const func = shallow ? shallowMount : mount; const func = shallow ? shallowMount : mount;
...@@ -109,6 +109,7 @@ describe('StageBuildItem', () => { ...@@ -109,6 +109,7 @@ describe('StageBuildItem', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ withBuildStatus: true }, false); wrapper = createComponent({ withBuildStatus: true }, false);
}); });
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
......
...@@ -102,8 +102,12 @@ describe('Stage', () => { ...@@ -102,8 +102,12 @@ describe('Stage', () => {
${'Code'} | ${codeStage} | ${codeEvents} ${'Code'} | ${codeStage} | ${codeEvents}
${'Production'} | ${productionStage} | ${productionEvents} ${'Production'} | ${productionStage} | ${productionEvents}
`('$name stage will render the list of events', ({ stage, eventList }) => { `('$name stage will render the list of events', ({ stage, eventList }) => {
wrapper = createComponent({ props: { stage, events: eventList } }); // stages generated from fixtures may not have events
eventList.forEach((item, index) => { const events = eventList.length ? eventList : generateEvents(5);
wrapper = createComponent({
props: { stage, events },
});
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index); const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.title).text()).toContain(item.title); expect(elem.find($sel.title).text()).toContain(item.title);
}); });
......
// NOTE: more tests will be added in https://gitlab.com/gitlab-org/gitlab/issues/121613
import { shallowMount } from '@vue/test-utils';
import StageNavItem from 'ee/analytics/cycle_analytics/components/stage_nav_item.vue';
import { approximateDuration } from '~/lib/utils/datetime_utility';
describe('StageNavItem', () => {
const title = 'Rad stage';
const median = 50;
const $sel = {
title: '.stage-name',
median: '.stage-median',
};
function createComponent(props) {
return shallowMount(StageNavItem, {
propsData: {
title,
value: median,
...props,
},
});
}
let wrapper = null;
const findStageTitle = () => wrapper.find($sel.title);
const findStageMedian = () => wrapper.find($sel.median);
afterEach(() => {
wrapper.destroy();
});
it('with no median value', () => {
wrapper = createComponent({ value: null });
expect(findStageMedian().text()).toEqual('Not enough data');
});
describe('with data', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('renders the median value', () => {
expect(findStageMedian().text()).toEqual(approximateDuration(median));
});
it('renders the stage title', () => {
expect(findStageTitle().text()).toEqual(title);
});
});
});
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
allowedStages, allowedStages,
groupLabels, groupLabels,
customStageEvents, customStageEvents,
stageMedians as medians,
} from '../mock_data'; } from '../mock_data';
let wrapper = null; let wrapper = null;
...@@ -43,6 +44,7 @@ function createComponent(props = {}, shallow = false) { ...@@ -43,6 +44,7 @@ function createComponent(props = {}, shallow = false) {
noAccessSvgPath, noAccessSvgPath,
canEditStages: false, canEditStages: false,
customStageFormEvents: customStageEvents, customStageFormEvents: customStageEvents,
medians,
...props, ...props,
}, },
stubs: { stubs: {
......
...@@ -14,7 +14,8 @@ import { mockLabels } from '../../../../../spec/javascripts/vue_shared/component ...@@ -14,7 +14,8 @@ import { mockLabels } from '../../../../../spec/javascripts/vue_shared/component
const endpoints = { const endpoints = {
cycleAnalyticsData: 'cycle_analytics/mock_data.json', // existing cycle analytics data cycleAnalyticsData: 'cycle_analytics/mock_data.json', // existing cycle analytics data
customizableCycleAnalyticsStagesAndEvents: 'analytics/cycle_analytics/stages.json', // customizable stages and events endpoint customizableCycleAnalyticsStagesAndEvents: 'analytics/cycle_analytics/stages.json', // customizable stages and events endpoint
stageEvents: stage => `cycle_analytics/events/${stage}.json`, stageEvents: stage => `analytics/cycle_analytics/stages/${stage}/records.json`,
stageMedian: stage => `analytics/cycle_analytics/stages/${stage}/median.json`,
}; };
export const groupLabels = mockLabels.map(({ title, ...rest }) => ({ ...rest, name: title })); export const groupLabels = mockLabels.map(({ title, ...rest }) => ({ ...rest, name: title }));
...@@ -27,7 +28,8 @@ export const group = { ...@@ -27,7 +28,8 @@ export const group = {
avatar_url: `${TEST_HOST}/images/home/nasa.svg`, avatar_url: `${TEST_HOST}/images/home/nasa.svg`,
}; };
const getStageById = (stages, id) => stages.find(stage => stage.id === id) || {}; const getStageByTitle = (stages, title) =>
stages.find(stage => stage.title && stage.title.toLowerCase().trim() === title) || {};
export const cycleAnalyticsData = getJSONFixture(endpoints.cycleAnalyticsData); export const cycleAnalyticsData = getJSONFixture(endpoints.cycleAnalyticsData);
...@@ -40,41 +42,47 @@ const dummyState = {}; ...@@ -40,41 +42,47 @@ const dummyState = {};
// prepare the raw stage data for our components // prepare the raw stage data for our components
mutations[types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS](dummyState, customizableStagesAndEvents); mutations[types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS](dummyState, customizableStagesAndEvents);
export const issueStage = getStageById(dummyState.stages, 'issue'); export const issueStage = getStageByTitle(dummyState.stages, 'issue');
export const planStage = getStageById(dummyState.stages, 'plan'); export const planStage = getStageByTitle(dummyState.stages, 'plan');
export const reviewStage = getStageById(dummyState.stages, 'review'); export const reviewStage = getStageByTitle(dummyState.stages, 'review');
export const codeStage = getStageById(dummyState.stages, 'code'); export const codeStage = getStageByTitle(dummyState.stages, 'code');
export const testStage = getStageById(dummyState.stages, 'test'); export const testStage = getStageByTitle(dummyState.stages, 'test');
export const stagingStage = getStageById(dummyState.stages, 'staging'); export const stagingStage = getStageByTitle(dummyState.stages, 'staging');
export const productionStage = getStageById(dummyState.stages, 'production'); export const productionStage = getStageByTitle(dummyState.stages, 'production');
export const allowedStages = [issueStage, planStage, codeStage]; export const allowedStages = [issueStage, planStage, codeStage];
const rawIssueEvents = getJSONFixture('cycle_analytics/events/issue.json');
export const rawEvents = rawIssueEvents.events;
const deepCamelCase = obj => convertObjectPropsToCamelCase(obj, { deep: true }); const deepCamelCase = obj => convertObjectPropsToCamelCase(obj, { deep: true });
export const defaultStages = ['issue', 'plan', 'review', 'code', 'test', 'staging', 'production']; export const defaultStages = ['issue', 'plan', 'review', 'code', 'test', 'staging', 'production'];
const stageFixtures = defaultStages.reduce((acc, stage) => { const stageFixtures = defaultStages.reduce((acc, stage) => {
const { events } = getJSONFixture(endpoints.stageEvents(stage)); const events = getJSONFixture(endpoints.stageEvents(stage));
return { return {
...acc, ...acc,
[stage]: deepCamelCase(events), [stage]: events,
};
}, {});
export const stageMedians = defaultStages.reduce((acc, stage) => {
const { value } = getJSONFixture(endpoints.stageMedian(stage));
return {
...acc,
[stage]: value,
}; };
}, {}); }, {});
export const endDate = new Date(2019, 0, 14); 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 rawIssueEvents = stageFixtures.issue;
export const planEvents = stageFixtures.plan; export const issueEvents = deepCamelCase(stageFixtures.issue);
export const reviewEvents = stageFixtures.review; export const planEvents = deepCamelCase(stageFixtures.plan);
export const codeEvents = stageFixtures.code; export const reviewEvents = deepCamelCase(stageFixtures.review);
export const testEvents = stageFixtures.test; export const codeEvents = deepCamelCase(stageFixtures.code);
export const stagingEvents = stageFixtures.staging; export const testEvents = deepCamelCase(stageFixtures.test);
export const productionEvents = stageFixtures.production; export const stagingEvents = deepCamelCase(stageFixtures.staging);
export const productionEvents = deepCamelCase(stageFixtures.production);
export const rawCustomStage = { export const rawCustomStage = {
title: 'Coolest beans stage', title: 'Coolest beans stage',
hidden: false, hidden: false,
...@@ -86,6 +94,8 @@ export const rawCustomStage = { ...@@ -86,6 +94,8 @@ export const rawCustomStage = {
end_event_identifier: 'issue_first_added_to_board', end_event_identifier: 'issue_first_added_to_board',
}; };
export const medians = stageMedians;
const { events: rawCustomStageEvents } = customizableStagesAndEvents; const { events: rawCustomStageEvents } = customizableStagesAndEvents;
const camelCasedStageEvents = rawCustomStageEvents.map(deepCamelCase); const camelCasedStageEvents = rawCustomStageEvents.map(deepCamelCase);
...@@ -130,12 +140,12 @@ export const rawDurationData = [ ...@@ -130,12 +140,12 @@ export const rawDurationData = [
export const transformedDurationData = [ export const transformedDurationData = [
{ {
slug: 'issue', slug: 1,
selected: true, selected: true,
data: rawDurationData, data: rawDurationData,
}, },
{ {
slug: 'plan', slug: 2,
selected: true, selected: true,
data: rawDurationData, data: rawDurationData,
}, },
......
...@@ -4,7 +4,7 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; ...@@ -4,7 +4,7 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { import {
cycleAnalyticsData, cycleAnalyticsData,
rawEvents, rawIssueEvents,
issueEvents as transformedEvents, issueEvents as transformedEvents,
issueStage, issueStage,
planStage, planStage,
...@@ -58,6 +58,8 @@ describe('Cycle analytics mutations', () => { ...@@ -58,6 +58,8 @@ describe('Cycle analytics mutations', () => {
${types.RECEIVE_REMOVE_STAGE_RESPONSE} | ${'isLoading'} | ${false} ${types.RECEIVE_REMOVE_STAGE_RESPONSE} | ${'isLoading'} | ${false}
${types.REQUEST_DURATION_DATA} | ${'isLoadingDurationChart'} | ${true} ${types.REQUEST_DURATION_DATA} | ${'isLoadingDurationChart'} | ${true}
${types.RECEIVE_DURATION_DATA_ERROR} | ${'isLoadingDurationChart'} | ${false} ${types.RECEIVE_DURATION_DATA_ERROR} | ${'isLoadingDurationChart'} | ${false}
${types.REQUEST_STAGE_MEDIANS} | ${'medians'} | ${{}}
${types.RECEIVE_STAGE_MEDIANS_ERROR} | ${'medians'} | ${{}}
`('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => { `('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => {
mutations[mutation](state); mutations[mutation](state);
...@@ -87,7 +89,7 @@ describe('Cycle analytics mutations', () => { ...@@ -87,7 +89,7 @@ describe('Cycle analytics mutations', () => {
describe(`${types.RECEIVE_STAGE_DATA_SUCCESS}`, () => { describe(`${types.RECEIVE_STAGE_DATA_SUCCESS}`, () => {
it('will set the currentStageEvents state item with the camelCased events', () => { it('will set the currentStageEvents state item with the camelCased events', () => {
mutations[types.RECEIVE_STAGE_DATA_SUCCESS](state, { events: rawEvents }); mutations[types.RECEIVE_STAGE_DATA_SUCCESS](state, rawIssueEvents);
expect(state.currentStageEvents).toEqual(transformedEvents); expect(state.currentStageEvents).toEqual(transformedEvents);
}); });
...@@ -99,7 +101,7 @@ describe('Cycle analytics mutations', () => { ...@@ -99,7 +101,7 @@ describe('Cycle analytics mutations', () => {
}); });
it('will set isEmptyStage=false if currentStageEvents.length > 0', () => { it('will set isEmptyStage=false if currentStageEvents.length > 0', () => {
mutations[types.RECEIVE_STAGE_DATA_SUCCESS](state, { events: rawEvents }); mutations[types.RECEIVE_STAGE_DATA_SUCCESS](state, rawIssueEvents);
expect(state.isEmptyStage).toEqual(false); expect(state.isEmptyStage).toEqual(false);
}); });
...@@ -170,20 +172,6 @@ describe('Cycle analytics mutations', () => { ...@@ -170,20 +172,6 @@ describe('Cycle analytics mutations', () => {
mutations[types.RECEIVE_SUMMARY_DATA_SUCCESS](state, { mutations[types.RECEIVE_SUMMARY_DATA_SUCCESS](state, {
...cycleAnalyticsData, ...cycleAnalyticsData,
summary: [{ value: 0, title: 'New Issues' }, { value: 0, title: 'Deploys' }], 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,
},
],
}); });
}); });
...@@ -193,31 +181,6 @@ describe('Cycle analytics mutations', () => { ...@@ -193,31 +181,6 @@ describe('Cycle analytics mutations', () => {
{ value: '-', title: 'Deploys' }, { 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 },
]);
});
describe('with hidden stages', () => {
const mockStages = customizableStagesAndEvents.stages;
beforeEach(() => {
mockStages[0].hidden = true;
mutations[types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS](state, {
...customizableStagesAndEvents.events,
stages: mockStages,
});
});
it('will only return stages that are not hidden', () => {
expect(state.stages.map(({ id }) => id)).not.toContain(mockStages[0].id);
});
});
}); });
describe(`${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR}`, () => { describe(`${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR}`, () => {
...@@ -259,4 +222,19 @@ describe('Cycle analytics mutations', () => { ...@@ -259,4 +222,19 @@ describe('Cycle analytics mutations', () => {
expect(stateWithData.durationData).toBe(transformedDurationData); expect(stateWithData.durationData).toBe(transformedDurationData);
}); });
}); });
describe(`${types.RECEIVE_STAGE_MEDIANS_SUCCESS}`, () => {
it('sets each id as a key in the median object with the corresponding value', () => {
const stateWithData = {
medians: {},
};
mutations[types.RECEIVE_STAGE_MEDIANS_SUCCESS](stateWithData, [
{ id: 1, value: 20 },
{ id: 2, value: 10 },
]);
expect(stateWithData.medians).toEqual({ '1': 20, '2': 10 });
});
});
}); });
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
flattenDurationChartData, flattenDurationChartData,
getDurationChartData, getDurationChartData,
transformRawStages, transformRawStages,
isPersistedStage,
} from 'ee/analytics/cycle_analytics/utils'; } from 'ee/analytics/cycle_analytics/utils';
import { import {
customStageEvents as events, customStageEvents as events,
...@@ -177,5 +178,25 @@ describe('Cycle analytics utils', () => { ...@@ -177,5 +178,25 @@ describe('Cycle analytics utils', () => {
expect(t.slug).toEqual(t.id); expect(t.slug).toEqual(t.id);
}); });
}); });
it('sets the name to the value of the stage title if its not set', () => {
const transformed = transformRawStages([issueStage, rawCustomStage]);
transformed.forEach(t => {
expect(t.name.length > 0).toBe(true);
expect(t.name).toEqual(t.title);
});
});
});
describe('isPersistedStage', () => {
it.each`
custom | id | expected
${true} | ${'this-is-a-string'} | ${true}
${true} | ${42} | ${true}
${false} | ${42} | ${true}
${false} | ${'this-is-a-string'} | ${false}
`('with custom=$custom and id=$id', ({ custom, id, expected }) => {
expect(isPersistedStage({ custom, id })).toEqual(expected);
});
}); });
}); });
...@@ -422,11 +422,11 @@ describe('Api', () => { ...@@ -422,11 +422,11 @@ describe('Api', () => {
it('fetches stage events', done => { it('fetches stage events', done => {
const response = { events: [] }; const response = { events: [] };
const params = { const params = {
'cycle_analytics[group_id]': groupId, group_id: groupId,
'cycle_analytics[created_after]': createdAfter, created_after: createdAfter,
'cycle_analytics[created_before]': createdBefore, created_before: createdBefore,
}; };
const expectedUrl = `${dummyUrlRoot}/groups/${groupId}/-/cycle_analytics/events/${stageId}.json`; const expectedUrl = `${dummyUrlRoot}/-/analytics/cycle_analytics/stages/${stageId}/records`;
mock.onGet(expectedUrl).reply(200, response); mock.onGet(expectedUrl).reply(200, response);
Api.cycleAnalyticsStageEvents(groupId, stageId, params) Api.cycleAnalyticsStageEvents(groupId, stageId, params)
...@@ -442,6 +442,30 @@ describe('Api', () => { ...@@ -442,6 +442,30 @@ describe('Api', () => {
}); });
}); });
describe('cycleAnalyticsStageMedian', () => {
it('fetches stage events', done => {
const response = { value: '5 days ago' };
const params = {
group_id: groupId,
created_after: createdAfter,
created_before: createdBefore,
};
const expectedUrl = `${dummyUrlRoot}/-/analytics/cycle_analytics/stages/${stageId}/median`;
mock.onGet(expectedUrl).reply(200, response);
Api.cycleAnalyticsStageMedian(groupId, stageId, params)
.then(responseObj =>
expectRequestWithCorrectParameters(responseObj, {
response,
params,
expectedUrl,
}),
)
.then(done)
.catch(done.fail);
});
});
describe('cycleAnalyticsCreateStage', () => { describe('cycleAnalyticsCreateStage', () => {
it('submit the custom stage data', done => { it('submit the custom stage data', done => {
const response = {}; const response = {};
......
...@@ -7,43 +7,85 @@ describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do ...@@ -7,43 +7,85 @@ describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do
let(:group) { create(:group)} let(:group) { create(:group)}
let(:project) { create(:project, :repository, namespace: group) } let(:project) { create(:project, :repository, namespace: group) }
let(:user) { create(:user, :admin) } let(:user) { create(:user, :admin) }
let(:issue) { create(:issue, project: project, created_at: 4.days.ago) } # let(:issue) { create(:issue, project: project, created_at: 4.days.ago) }
let(:milestone) { create(:milestone, project: project) } let(:milestone) { create(:milestone, project: project) }
# let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") }
let(:issue) { create(:issue, project: project, created_at: 4.days.ago) }
let(:issue_1) { create(:issue, project: project, created_at: 5.days.ago) }
let(:issue_2) { create(:issue, project: project, created_at: 4.days.ago) }
let(:issue_3) { create(:issue, project: project, created_at: 3.days.ago) }
let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") } let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") }
let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) } let(:mr_1) { create(:merge_request, source_project: project, allow_broken: true, created_at: 20.days.ago) }
let(:build) { create(:ci_build, :success, pipeline: pipeline, author: user) } let(:mr_2) { create(:merge_request, source_project: project, allow_broken: true, created_at: 19.days.ago) }
let(:mr_3) { create(:merge_request, source_project: project, allow_broken: true, created_at: 18.days.ago) }
let!(:issue_1) { create(:issue, project: project, created_at: 5.days.ago) } let(:pipeline_1) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr_1.source_branch, sha: mr_1.source_branch_sha, head_pipeline_of: mr_1) }
let!(:issue_2) { create(:issue, project: project, created_at: 4.days.ago) } let(:pipeline_2) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr_2.source_branch, sha: mr_2.source_branch_sha, head_pipeline_of: mr_2) }
let!(:issue_3) { create(:issue, project: project, created_at: 3.days.ago) } let(:pipeline_3) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr_3.source_branch, sha: mr_3.source_branch_sha, head_pipeline_of: mr_3) }
let!(:mr_1) { create_merge_request_closing_issue(user, project, issue_1) } let(:build_1) { create(:ci_build, :success, pipeline: pipeline_1, author: user) }
let!(:mr_2) { create_merge_request_closing_issue(user, project, issue_2) } let(:build_2) { create(:ci_build, :success, pipeline: pipeline_2, author: user) }
let!(:mr_3) { create_merge_request_closing_issue(user, project, issue_3) } let(:build_3) { create(:ci_build, :success, pipeline: pipeline_3, author: user) }
def prepare_cycle_analytics_data def prepare_cycle_analytics_data
group.add_maintainer(user) group.add_maintainer(user)
project.add_maintainer(user) project.add_maintainer(user)
create_cycle(user, project, issue, mr, milestone, pipeline) create_cycle(user, project, issue_1, mr_1, milestone, pipeline_1)
create_cycle(user, project, issue_2, mr_2, milestone, pipeline) create_cycle(user, project, issue_2, mr_2, milestone, pipeline_2)
create_commit_referencing_issue(issue_1) create_commit_referencing_issue(issue_1)
create_commit_referencing_issue(issue_2) create_commit_referencing_issue(issue_2)
create_merge_request_closing_issue(user, project, issue_1) create_merge_request_closing_issue(user, project, issue_1)
create_merge_request_closing_issue(user, project, issue_2) create_merge_request_closing_issue(user, project, issue_2)
merge_merge_requests_closing_issue(user, project, issue_3) merge_merge_requests_closing_issue(user, project, issue_3)
deploy_master(user, project, environment: 'staging') deploy_master(user, project, environment: 'staging')
deploy_master(user, project) deploy_master(user, project)
end end
def update_metrics
issue_1.metrics.update(first_added_to_board_at: 3.days.ago, first_mentioned_in_commit_at: 2.days.ago)
issue_2.metrics.update(first_added_to_board_at: 2.days.ago, first_mentioned_in_commit_at: 1.day.ago)
mr_1.metrics.update!({
merged_at: 5.days.ago,
first_deployed_to_production_at: 1.day.ago,
latest_build_started_at: 5.days.ago,
latest_build_finished_at: 1.day.ago,
pipeline: build_1.pipeline
})
mr_2.metrics.update!({
merged_at: 10.days.ago,
first_deployed_to_production_at: 5.days.ago,
latest_build_started_at: 9.days.ago,
latest_build_finished_at: 7.days.ago,
pipeline: build_2.pipeline
})
end
def additional_cycle_analytics_metrics
create(:cycle_analytics_group_stage, parent: group)
update_metrics
create_cycle(user, project, issue_1, mr_1, milestone, pipeline_1)
create_cycle(user, project, issue_2, mr_2, milestone, pipeline_2)
create_cycle(user, project, issue_3, mr_3, milestone, pipeline_3)
deploy_master(user, project, environment: 'staging')
end
before(:all) do before(:all) do
clean_frontend_fixtures('analytics/') clean_frontend_fixtures('analytics/')
clean_frontend_fixtures('cycle_analytics/')
end end
default_stages = %w[issue plan review code test staging production]
describe Groups::CycleAnalytics::EventsController, type: :controller do describe Groups::CycleAnalytics::EventsController, type: :controller do
render_views render_views
...@@ -55,8 +97,6 @@ describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do ...@@ -55,8 +97,6 @@ describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do
sign_in(user) sign_in(user)
end end
default_stages = %w[issue plan review code test staging production]
default_stages.each do |endpoint| default_stages.each do |endpoint|
it "cycle_analytics/events/#{endpoint}.json" do it "cycle_analytics/events/#{endpoint}.json" do
get endpoint, params: { group_id: group, format: :json } get endpoint, params: { group_id: group, format: :json }
...@@ -90,10 +130,21 @@ describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do ...@@ -90,10 +130,21 @@ describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do
describe Analytics::CycleAnalytics::StagesController, type: :controller do describe Analytics::CycleAnalytics::StagesController, type: :controller do
render_views render_views
let(:params) { { created_after: 3.months.ago, created_before: Time.now, group_id: group.full_path } }
before do before do
stub_feature_flags(Gitlab::Analytics::CYCLE_ANALYTICS_FEATURE_FLAG => true) stub_feature_flags(Gitlab::Analytics::CYCLE_ANALYTICS_FEATURE_FLAG => true)
stub_licensed_features(cycle_analytics_for_groups: true) stub_licensed_features(cycle_analytics_for_groups: true)
# Persist the default stages
Gitlab::Analytics::CycleAnalytics::DefaultStages.all.map do |params|
group.cycle_analytics_stages.build(params).save!
end
prepare_cycle_analytics_data
additional_cycle_analytics_metrics
sign_in(user) sign_in(user)
end end
...@@ -102,6 +153,22 @@ describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do ...@@ -102,6 +153,22 @@ describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do
expect(response).to be_successful expect(response).to be_successful
end end
Gitlab::Analytics::CycleAnalytics::DefaultStages.all.each do |stage|
it "analytics/cycle_analytics/stages/#{stage[:name]}/records.json" do
stage_id = group.cycle_analytics_stages.find_by(name: stage[:name]).id
get(:records, params: params.merge({ id: stage_id }), format: :json)
expect(response).to be_successful
end
it "analytics/cycle_analytics/stages/#{stage[:name]}/median.json" do
stage_id = group.cycle_analytics_stages.find_by(name: stage[:name]).id
get(:median, params: params.merge({ id: stage_id }), format: :json)
expect(response).to be_successful
end
end
end end
describe Analytics::TasksByTypeController, type: :controller do describe Analytics::TasksByTypeController, type: :controller do
......
...@@ -18239,15 +18239,18 @@ msgstr "" ...@@ -18239,15 +18239,18 @@ msgstr ""
msgid "There was an error fetching cycle analytics stages." msgid "There was an error fetching cycle analytics stages."
msgstr "" msgstr ""
msgid "There was an error fetching data for the chart" msgid "There was an error fetching data for the selected stage"
msgstr "" msgstr ""
msgid "There was an error fetching data for the selected stage" msgid "There was an error fetching data for the tasks by type chart"
msgstr "" 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 ""
msgid "There was an error fetching median data for stages"
msgstr ""
msgid "There was an error fetching the Designs" msgid "There was an error fetching the Designs"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment