Commit 737d8993 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch 'fix-handle-ca-custom-form-server-errors' into 'master'

Handle ca custom form server errors

Closes #36685

See merge request gitlab-org/gitlab!24218
parents 85bd310f 8b2aa804
...@@ -15,8 +15,8 @@ module Analytics ...@@ -15,8 +15,8 @@ module Analytics
validates :name, exclusion: { in: Gitlab::Analytics::CycleAnalytics::DefaultStages.names }, if: :custom? validates :name, exclusion: { in: Gitlab::Analytics::CycleAnalytics::DefaultStages.names }, if: :custom?
validates :start_event_identifier, presence: true validates :start_event_identifier, presence: true
validates :end_event_identifier, presence: true validates :end_event_identifier, presence: true
validates :start_event_label, presence: true, if: :start_event_label_based? validates :start_event_label_id, presence: true, if: :start_event_label_based?
validates :end_event_label, presence: true, if: :end_event_label_based? validates :end_event_label_id, presence: true, if: :end_event_label_based?
validate :validate_stage_event_pairs validate :validate_stage_event_pairs
validate :validate_labels validate :validate_labels
...@@ -109,8 +109,8 @@ module Analytics ...@@ -109,8 +109,8 @@ module Analytics
end end
def validate_labels def validate_labels
validate_label_within_group(:start_event_label, start_event_label_id) if start_event_label_id_changed? validate_label_within_group(:start_event_label_id, start_event_label_id) if start_event_label_id_changed?
validate_label_within_group(:end_event_label, end_event_label_id) if end_event_label_id_changed? validate_label_within_group(:end_event_label_id, end_event_label_id) if end_event_label_id_changed?
end end
def validate_label_within_group(association_name, label_id) def validate_label_within_group(association_name, label_id)
......
...@@ -68,6 +68,7 @@ export default { ...@@ -68,6 +68,7 @@ export default {
'endDate', 'endDate',
'tasksByType', 'tasksByType',
'medians', 'medians',
'customStageFormErrors',
]), ]),
...mapGetters([ ...mapGetters([
'hasNoAccessError', 'hasNoAccessError',
...@@ -135,6 +136,7 @@ export default { ...@@ -135,6 +136,7 @@ export default {
'setSelectedStage', 'setSelectedStage',
'hideCustomStageForm', 'hideCustomStageForm',
'showCustomStageForm', 'showCustomStageForm',
'showEditCustomStageForm',
'setDateRange', 'setDateRange',
'fetchTasksByTypeData', 'fetchTasksByTypeData',
'updateSelectedDurationChartStages', 'updateSelectedDurationChartStages',
...@@ -142,7 +144,7 @@ export default { ...@@ -142,7 +144,7 @@ export default {
'updateStage', 'updateStage',
'removeStage', 'removeStage',
'setFeatureFlags', 'setFeatureFlags',
'editCustomStage', 'clearCustomStageFormErrors',
'updateStage', 'updateStage',
'setTasksByTypeFilters', 'setTasksByTypeFilters',
]), ]),
...@@ -164,7 +166,7 @@ export default { ...@@ -164,7 +166,7 @@ export default {
this.showCustomStageForm(); this.showCustomStageForm();
}, },
onShowEditStageForm(initData = {}) { onShowEditStageForm(initData = {}) {
this.editCustomStage(initData); this.showEditCustomStageForm(initData);
}, },
initDateRange() { initDateRange() {
const endDate = new Date(Date.now()); const endDate = new Date(Date.now());
...@@ -278,10 +280,12 @@ export default { ...@@ -278,10 +280,12 @@ export default {
:is-editing-custom-stage="isEditingCustomStage" :is-editing-custom-stage="isEditingCustomStage"
:current-stage-events="currentStageEvents" :current-stage-events="currentStageEvents"
:custom-stage-form-events="customStageFormEvents" :custom-stage-form-events="customStageFormEvents"
:custom-stage-form-errors="customStageFormErrors"
:labels="labels" :labels="labels"
:no-data-svg-path="noDataSvgPath" :no-data-svg-path="noDataSvgPath"
:no-access-svg-path="noAccessSvgPath" :no-access-svg-path="noAccessSvgPath"
:can-edit-stages="hasCustomizableCycleAnalytics" :can-edit-stages="hasCustomizableCycleAnalytics"
@clearCustomStageFormErrors="clearCustomStageFormErrors"
@selectStage="onStageSelect" @selectStage="onStageSelect"
@editStage="onShowEditStageForm" @editStage="onShowEditStageForm"
@showAddStageForm="onShowAddStageForm" @showAddStageForm="onShowAddStageForm"
......
...@@ -14,7 +14,7 @@ import { ...@@ -14,7 +14,7 @@ import {
getLabelEventsIdentifiers, getLabelEventsIdentifiers,
} from '../utils'; } from '../utils';
const initFields = { const defaultFields = {
id: null, id: null,
name: null, name: null,
startEventIdentifier: null, startEventIdentifier: null,
...@@ -55,13 +55,27 @@ export default { ...@@ -55,13 +55,27 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
errors: {
type: Object,
required: false,
default: () => ({}),
},
}, },
data() { data() {
const defaultErrors = this?.initialFields?.endEventIdentifier
? {}
: { endEventIdentifier: [s__('CustomCycleAnalytics|Please select a start event first')] };
return { return {
labelEvents: getLabelEventsIdentifiers(this.events),
fields: { fields: {
...initFields, ...defaultFields,
...this.initialFields, ...this.initialFields,
}, },
fieldErrors: {
...defaultFields,
...this.errors,
...defaultErrors,
},
}; };
}, },
computed: { computed: {
...@@ -88,7 +102,7 @@ export default { ...@@ -88,7 +102,7 @@ export default {
return isLabelEvent(this.labelEvents, this.fields.endEventIdentifier); return isLabelEvent(this.labelEvents, this.fields.endEventIdentifier);
}, },
isComplete() { isComplete() {
if (!this.hasValidStartAndEndEventPair) { if (this.eventMismatchError) {
return false; return false;
} }
const { const {
...@@ -113,22 +127,16 @@ export default { ...@@ -113,22 +127,16 @@ export default {
); );
}, },
isDirty() { isDirty() {
return !isEqual(this.initialFields, this.fields) && !isEqual(initFields, this.fields); return !isEqual(this.initialFields, this.fields) && !isEqual(defaultFields, this.fields);
}, },
hasValidStartAndEndEventPair() { eventMismatchError() {
const { const {
fields: { startEventIdentifier, endEventIdentifier }, fields: { startEventIdentifier = null, endEventIdentifier = null },
} = this; } = this;
if (startEventIdentifier && endEventIdentifier) {
const endEvents = getAllowedEndEvents(this.events, startEventIdentifier); if (!startEventIdentifier || !endEventIdentifier) return true;
return endEvents.length && endEvents.includes(endEventIdentifier); const endEvents = getAllowedEndEvents(this.events, startEventIdentifier);
} return !endEvents.length || !endEvents.includes(endEventIdentifier);
return true;
},
endEventError() {
return !this.hasValidStartAndEndEventPair
? s__('CustomCycleAnalytics|Start event changed, please select a valid stop event')
: null;
}, },
saveStageText() { saveStageText() {
return this.isEditingCustomStage return this.isEditingCustomStage
...@@ -141,13 +149,22 @@ export default { ...@@ -141,13 +149,22 @@ export default {
: s__('CustomCycleAnalytics|New stage'); : s__('CustomCycleAnalytics|New stage');
}, },
}, },
mounted() { watch: {
this.labelEvents = getLabelEventsIdentifiers(this.events); initialFields(newFields) {
this.fields = {
...defaultFields,
...newFields,
};
this.fieldErrors = {
...defaultFields,
...this.errors,
};
},
}, },
methods: { methods: {
handleCancel() { handleCancel() {
this.fields = { this.fields = {
...initFields, ...defaultFields,
...this.initialFields, ...this.initialFields,
}; };
this.$emit('cancel'); this.$emit('cancel');
...@@ -167,6 +184,24 @@ export default { ...@@ -167,6 +184,24 @@ export default {
handleClearLabel(key) { handleClearLabel(key) {
this.fields[key] = null; this.fields[key] = null;
}, },
hasFieldErrors(key) {
return this.fieldErrors[key]?.length > 0;
},
fieldErrorMessage(key) {
return this.fieldErrors[key]?.join('\n');
},
onUpdateStartEventField() {
const initVal = this.initialFields?.endEventIdentifier
? this.initialFields.endEventIdentifier
: null;
this.$set(this.fields, 'endEventIdentifier', initVal);
this.$set(this.fieldErrors, 'endEventIdentifier', [
s__('CustomCycleAnalytics|Start event changed, please select a valid stop event'),
]);
},
onUpdateEndEventField() {
this.$set(this.fieldErrors, 'endEventIdentifier', null);
},
}, },
}; };
</script> </script>
...@@ -175,7 +210,13 @@ export default { ...@@ -175,7 +210,13 @@ export default {
<div class="mb-1"> <div class="mb-1">
<h4>{{ formTitle }}</h4> <h4>{{ formTitle }}</h4>
</div> </div>
<gl-form-group :label="s__('CustomCycleAnalytics|Name')">
<gl-form-group
ref="name"
:label="s__('CustomCycleAnalytics|Name')"
:state="!hasFieldErrors('name')"
:invalid-feedback="fieldErrorMessage('name')"
>
<gl-form-input <gl-form-input
v-model="fields.name" v-model="fields.name"
class="form-control" class="form-control"
...@@ -187,17 +228,28 @@ export default { ...@@ -187,17 +228,28 @@ export default {
</gl-form-group> </gl-form-group>
<div class="d-flex" :class="{ 'justify-content-between': startEventRequiresLabel }"> <div class="d-flex" :class="{ 'justify-content-between': startEventRequiresLabel }">
<div :class="[startEventRequiresLabel ? 'w-50 mr-1' : 'w-100']"> <div :class="[startEventRequiresLabel ? 'w-50 mr-1' : 'w-100']">
<gl-form-group :label="s__('CustomCycleAnalytics|Start event')"> <gl-form-group
ref="startEventIdentifier"
:label="s__('CustomCycleAnalytics|Start event')"
:state="!hasFieldErrors('startEventIdentifier')"
:invalid-feedback="fieldErrorMessage('startEventIdentifier')"
>
<gl-form-select <gl-form-select
v-model="fields.startEventIdentifier" v-model="fields.startEventIdentifier"
name="custom-stage-start-event" name="custom-stage-start-event"
:required="true" :required="true"
:options="startEventOptions" :options="startEventOptions"
@change.native="onUpdateStartEventField"
/> />
</gl-form-group> </gl-form-group>
</div> </div>
<div v-if="startEventRequiresLabel" class="w-50 ml-1"> <div v-if="startEventRequiresLabel" class="w-50 ml-1">
<gl-form-group :label="s__('CustomCycleAnalytics|Start event label')"> <gl-form-group
ref="startEventLabelId"
:label="s__('CustomCycleAnalytics|Start event label')"
:state="!hasFieldErrors('startEventLabelId')"
:invalid-feedback="fieldErrorMessage('startEventLabelId')"
>
<labels-selector <labels-selector
:labels="labels" :labels="labels"
:selected-label-id="fields.startEventLabelId" :selected-label-id="fields.startEventLabelId"
...@@ -211,12 +263,11 @@ export default { ...@@ -211,12 +263,11 @@ export default {
<div class="d-flex" :class="{ 'justify-content-between': endEventRequiresLabel }"> <div class="d-flex" :class="{ 'justify-content-between': endEventRequiresLabel }">
<div :class="[endEventRequiresLabel ? 'w-50 mr-1' : 'w-100']"> <div :class="[endEventRequiresLabel ? 'w-50 mr-1' : 'w-100']">
<gl-form-group <gl-form-group
ref="endEventIdentifier"
:label="s__('CustomCycleAnalytics|Stop event')" :label="s__('CustomCycleAnalytics|Stop event')"
:description=" :state="!hasFieldErrors('endEventIdentifier')"
!hasStartEvent ? s__('CustomCycleAnalytics|Please select a start event first') : '' :invalid-feedback="fieldErrorMessage('endEventIdentifier')"
" @change.native="onUpdateEndEventField"
:state="hasValidStartAndEndEventPair"
:invalid-feedback="endEventError"
> >
<gl-form-select <gl-form-select
v-model="fields.endEventIdentifier" v-model="fields.endEventIdentifier"
...@@ -228,7 +279,12 @@ export default { ...@@ -228,7 +279,12 @@ export default {
</gl-form-group> </gl-form-group>
</div> </div>
<div v-if="endEventRequiresLabel" class="w-50 ml-1"> <div v-if="endEventRequiresLabel" class="w-50 ml-1">
<gl-form-group :label="s__('CustomCycleAnalytics|Stop event label')"> <gl-form-group
ref="endEventLabelId"
:label="s__('CustomCycleAnalytics|Stop event label')"
:state="!hasFieldErrors('endEventLabelId')"
:invalid-feedback="fieldErrorMessage('endEventLabelId')"
>
<labels-selector <labels-selector
:labels="labels" :labels="labels"
:selected-label-id="fields.endEventLabelId" :selected-label-id="fields.endEventLabelId"
......
<script> <script>
import { mapState } from 'vuex';
import { GlTooltipDirective, GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; import { GlTooltipDirective, GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import StageNavItem from './stage_nav_item.vue'; import StageNavItem from './stage_nav_item.vue';
...@@ -63,6 +64,11 @@ export default { ...@@ -63,6 +64,11 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
customStageFormErrors: {
type: Object,
required: false,
default: () => {},
},
labels: { labels: {
type: Array, type: Array,
required: true, required: true,
...@@ -86,6 +92,7 @@ export default { ...@@ -86,6 +92,7 @@ export default {
}; };
}, },
computed: { computed: {
...mapState(['customStageFormInitialData']),
stageEventsHeight() { stageEventsHeight() {
return `${this.stageNavHeight}px`; return `${this.stageNavHeight}px`;
}, },
...@@ -127,27 +134,6 @@ export default { ...@@ -127,27 +134,6 @@ export default {
}, },
]; ];
}, },
customStageInitialData() {
if (this.isEditingCustomStage) {
const {
id = null,
name = null,
startEventIdentifier = null,
startEventLabel: { id: startEventLabelId = null } = {},
endEventIdentifier = null,
endEventLabel: { id: endEventLabelId = null } = {},
} = this.currentStage;
return {
id,
name,
startEventIdentifier,
startEventLabelId,
endEventIdentifier,
endEventLabelId,
};
}
return {};
},
}, },
mounted() { mounted() {
this.$set(this, 'stageNavHeight', this.$refs.stageNav.clientHeight); this.$set(this, 'stageNavHeight', this.$refs.stageNav.clientHeight);
...@@ -207,11 +193,13 @@ export default { ...@@ -207,11 +193,13 @@ export default {
:events="customStageFormEvents" :events="customStageFormEvents"
:labels="labels" :labels="labels"
:is-saving-custom-stage="isSavingCustomStage" :is-saving-custom-stage="isSavingCustomStage"
:initial-fields="customStageInitialData" :initial-fields="customStageFormInitialData"
:is-editing-custom-stage="isEditingCustomStage" :is-editing-custom-stage="isEditingCustomStage"
:errors="customStageFormErrors"
@submit="$emit('submit', $event)" @submit="$emit('submit', $event)"
@createStage="$emit($options.STAGE_ACTIONS.CREATE, $event)" @createStage="$emit($options.STAGE_ACTIONS.CREATE, $event)"
@updateStage="$emit($options.STAGE_ACTIONS.UPDATE, $event)" @updateStage="$emit($options.STAGE_ACTIONS.UPDATE, $event)"
@clearErrors="$emit('clearCustomStageFormErrors')"
/> />
<template v-else> <template v-else>
<stage-event-list <stage-event-list
......
...@@ -2,7 +2,7 @@ import dateFormat from 'dateformat'; ...@@ -2,7 +2,7 @@ import dateFormat from 'dateformat';
import Api from 'ee/api'; import Api from 'ee/api';
import { getDayDifference, getDateInPast } from '~/lib/utils/datetime_utility'; import { getDayDifference, getDateInPast } from '~/lib/utils/datetime_utility';
import createFlash, { hideFlash } from '~/flash'; import createFlash, { hideFlash } from '~/flash';
import { __ } from '~/locale'; import { __, sprintf } from '~/locale';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { dateFormats } from '../../shared/constants'; import { dateFormats } from '../../shared/constants';
...@@ -21,6 +21,14 @@ const handleErrorOrRethrow = ({ action, error }) => { ...@@ -21,6 +21,14 @@ const handleErrorOrRethrow = ({ action, error }) => {
action(); action();
}; };
const isStageNameExistsError = ({ status, errors }) => {
const ERROR_NAME_RESERVED = 'is reserved';
if (status === httpStatus.UNPROCESSABLE_ENTITY) {
if (errors?.name?.includes(ERROR_NAME_RESERVED)) return true;
}
return false;
};
export const setFeatureFlags = ({ commit }, featureFlags) => export const setFeatureFlags = ({ commit }, featureFlags) =>
commit(types.SET_FEATURE_FLAGS, featureFlags); commit(types.SET_FEATURE_FLAGS, featureFlags);
export const setSelectedGroup = ({ commit }, group) => commit(types.SET_SELECTED_GROUP, group); export const setSelectedGroup = ({ commit }, group) => commit(types.SET_SELECTED_GROUP, group);
...@@ -135,12 +143,36 @@ export const fetchCycleAnalyticsData = ({ dispatch }) => { ...@@ -135,12 +143,36 @@ export const fetchCycleAnalyticsData = ({ dispatch }) => {
.catch(error => dispatch('receiveCycleAnalyticsDataError', error)); .catch(error => dispatch('receiveCycleAnalyticsDataError', error));
}; };
export const hideCustomStageForm = ({ commit }) => commit(types.HIDE_CUSTOM_STAGE_FORM); export const hideCustomStageForm = ({ commit }) => {
export const showCustomStageForm = ({ commit }) => commit(types.SHOW_CUSTOM_STAGE_FORM); commit(types.HIDE_CUSTOM_STAGE_FORM);
removeError();
};
export const showCustomStageForm = ({ commit }) => {
commit(types.SHOW_CUSTOM_STAGE_FORM);
removeError();
};
export const editCustomStage = ({ commit, dispatch }, selectedStage = {}) => { export const showEditCustomStageForm = ({ commit, dispatch }, selectedStage = {}) => {
commit(types.EDIT_CUSTOM_STAGE); const {
id = null,
name = null,
startEventIdentifier = null,
startEventLabel: { id: startEventLabelId = null } = {},
endEventIdentifier = null,
endEventLabel: { id: endEventLabelId = null } = {},
} = selectedStage;
commit(types.SHOW_EDIT_CUSTOM_STAGE_FORM, {
id,
name,
startEventIdentifier,
startEventLabelId,
endEventIdentifier,
endEventLabelId,
});
dispatch('setSelectedStage', selectedStage); dispatch('setSelectedStage', selectedStage);
removeError();
}; };
export const requestSummaryData = ({ commit }) => commit(types.REQUEST_SUMMARY_DATA); export const requestSummaryData = ({ commit }) => commit(types.REQUEST_SUMMARY_DATA);
...@@ -236,27 +268,36 @@ export const fetchGroupStagesAndEvents = ({ state, dispatch, getters }) => { ...@@ -236,27 +268,36 @@ export const fetchGroupStagesAndEvents = ({ state, dispatch, getters }) => {
); );
}; };
export const clearCustomStageFormErrors = ({ commit }) => {
commit(types.CLEAR_CUSTOM_STAGE_FORM_ERRORS);
removeError();
};
export const requestCreateCustomStage = ({ commit }) => commit(types.REQUEST_CREATE_CUSTOM_STAGE); export const requestCreateCustomStage = ({ commit }) => commit(types.REQUEST_CREATE_CUSTOM_STAGE);
export const receiveCreateCustomStageSuccess = ({ commit, dispatch }, { data: { title } }) => { export const receiveCreateCustomStageSuccess = ({ commit, dispatch }, { data: { title } }) => {
commit(types.RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE); commit(types.RECEIVE_CREATE_CUSTOM_STAGE_SUCCESS);
createFlash(__(`Your custom stage '${title}' was created`), 'notice'); createFlash(sprintf(__(`Your custom stage '%{title}' was created`), { title }), 'notice');
return dispatch('fetchGroupStagesAndEvents').then(() => dispatch('fetchSummaryData')); return Promise.resolve()
.then(() => dispatch('fetchGroupStagesAndEvents'))
.then(() => dispatch('fetchSummaryData'))
.catch(() => {
createFlash(__('There was a problem refreshing the data, please try again'));
});
}; };
export const receiveCreateCustomStageError = ({ commit }, { error, data }) => { export const receiveCreateCustomStageError = (
commit(types.RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE); { commit },
{ status = 400, errors = {}, data = {} } = {},
const { name } = data; ) => {
const { status } = error; commit(types.RECEIVE_CREATE_CUSTOM_STAGE_ERROR, { errors });
// TODO: check for 403, 422 etc const { name = null } = data;
// Follow up issue to investigate https://gitlab.com/gitlab-org/gitlab/issues/36685 const flashMessage =
const message = name && isStageNameExistsError({ status, errors })
status !== httpStatus.UNPROCESSABLE_ENTITY ? sprintf(__(`'%{name}' stage already exists`), { name })
? __(`'${name}' stage already exists'`)
: __('There was a problem saving your custom stage, please try again'); : __('There was a problem saving your custom stage, please try again');
createFlash(message); createFlash(flashMessage);
}; };
export const createCustomStage = ({ dispatch, state }, data) => { export const createCustomStage = ({ dispatch, state }, data) => {
...@@ -266,8 +307,15 @@ export const createCustomStage = ({ dispatch, state }, data) => { ...@@ -266,8 +307,15 @@ export const createCustomStage = ({ dispatch, state }, data) => {
dispatch('requestCreateCustomStage'); dispatch('requestCreateCustomStage');
return Api.cycleAnalyticsCreateStage(fullPath, data) return Api.cycleAnalyticsCreateStage(fullPath, data)
.then(response => dispatch('receiveCreateCustomStageSuccess', response)) .then(response => {
.catch(error => dispatch('receiveCreateCustomStageError', { error, data })); const { status, data: responseData } = response;
return dispatch('receiveCreateCustomStageSuccess', { status, data: responseData });
})
.catch(({ response } = {}) => {
const { data: { message, errors } = null, status = 400 } = response;
dispatch('receiveCreateCustomStageError', { data, message, errors, status });
});
}; };
export const receiveTasksByTypeDataSuccess = ({ commit }, data) => { export const receiveTasksByTypeDataSuccess = ({ commit }, data) => {
...@@ -313,28 +361,28 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => { ...@@ -313,28 +361,28 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => {
export const requestUpdateStage = ({ commit }) => commit(types.REQUEST_UPDATE_STAGE); export const requestUpdateStage = ({ commit }) => commit(types.REQUEST_UPDATE_STAGE);
export const receiveUpdateStageSuccess = ({ commit, dispatch }, updatedData) => { export const receiveUpdateStageSuccess = ({ commit, dispatch }, updatedData) => {
commit(types.RECEIVE_UPDATE_STAGE_RESPONSE); commit(types.RECEIVE_UPDATE_STAGE_SUCCESS);
createFlash(__('Stage data updated'), 'notice'); createFlash(__('Stage data updated'), 'notice');
dispatch('fetchGroupStagesAndEvents'); return Promise.all([
dispatch('setSelectedStage', updatedData); dispatch('fetchGroupStagesAndEvents'),
dispatch('setSelectedStage', updatedData),
]).catch(() => {
createFlash(__('There was a problem refreshing the data, please try again'));
});
}; };
export const receiveUpdateStageError = ( export const receiveUpdateStageError = (
{ commit }, { commit },
{ error: { response: { status = 400, data: errorData } = {} } = {}, data }, { status, responseData: { errors = null } = {}, data = {} },
) => { ) => {
commit(types.RECEIVE_UPDATE_STAGE_RESPONSE); commit(types.RECEIVE_UPDATE_STAGE_ERROR, { errors, data });
const ERROR_NAME_RESERVED = 'is reserved';
let message = __('There was a problem saving your custom stage, please try again'); const { name = null } = data;
if (status && status === httpStatus.UNPROCESSABLE_ENTITY) { const message =
const { errors } = errorData; name && isStageNameExistsError({ status, errors })
const { name } = data; ? sprintf(__(`'%{name}' stage already exists`), { name })
if (errors.name && errors.name[0] === ERROR_NAME_RESERVED) { : __('There was a problem saving your custom stage, please try again');
message = __(`'${name}' stage already exists`);
}
}
createFlash(__(message)); createFlash(__(message));
}; };
...@@ -348,7 +396,9 @@ export const updateStage = ({ dispatch, state }, { id, ...rest }) => { ...@@ -348,7 +396,9 @@ export const updateStage = ({ dispatch, state }, { id, ...rest }) => {
return Api.cycleAnalyticsUpdateStage(id, fullPath, { ...rest }) return Api.cycleAnalyticsUpdateStage(id, fullPath, { ...rest })
.then(({ data }) => dispatch('receiveUpdateStageSuccess', data)) .then(({ data }) => dispatch('receiveUpdateStageSuccess', data))
.catch(error => dispatch('receiveUpdateStageError', { error, data: { id, ...rest } })); .catch(({ response: { status = 400, data: responseData } = {} }) =>
dispatch('receiveUpdateStageError', { status, responseData, data: { id, ...rest } }),
);
}; };
export const requestRemoveStage = ({ commit }) => commit(types.REQUEST_REMOVE_STAGE); export const requestRemoveStage = ({ commit }) => commit(types.REQUEST_REMOVE_STAGE);
......
...@@ -21,7 +21,8 @@ export const RECEIVE_STAGE_MEDIANS_ERROR = 'RECEIVE_STAGE_MEDIANS_ERROR'; ...@@ -21,7 +21,8 @@ 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 SHOW_EDIT_CUSTOM_STAGE_FORM = 'SHOW_EDIT_CUSTOM_STAGE_FORM';
export const CLEAR_CUSTOM_STAGE_FORM_ERRORS = 'CLEAR_CUSTOM_STAGE_FORM_ERRORS';
export const REQUEST_GROUP_LABELS = 'REQUEST_GROUP_LABELS'; export const REQUEST_GROUP_LABELS = 'REQUEST_GROUP_LABELS';
export const RECEIVE_GROUP_LABELS_SUCCESS = 'RECEIVE_GROUP_LABELS_SUCCESS'; export const RECEIVE_GROUP_LABELS_SUCCESS = 'RECEIVE_GROUP_LABELS_SUCCESS';
...@@ -43,10 +44,12 @@ export const RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS = 'RECEIVE_TASKS_BY_TYPE_DATA_SU ...@@ -43,10 +44,12 @@ export const RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS = 'RECEIVE_TASKS_BY_TYPE_DATA_SU
export const RECEIVE_TASKS_BY_TYPE_DATA_ERROR = 'RECEIVE_TASKS_BY_TYPE_DATA_ERROR'; export const RECEIVE_TASKS_BY_TYPE_DATA_ERROR = 'RECEIVE_TASKS_BY_TYPE_DATA_ERROR';
export const REQUEST_CREATE_CUSTOM_STAGE = 'REQUEST_CREATE_CUSTOM_STAGE'; export const REQUEST_CREATE_CUSTOM_STAGE = 'REQUEST_CREATE_CUSTOM_STAGE';
export const RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE = 'RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE'; export const RECEIVE_CREATE_CUSTOM_STAGE_SUCCESS = 'RECEIVE_CREATE_CUSTOM_STAGE_SUCCESS';
export const RECEIVE_CREATE_CUSTOM_STAGE_ERROR = 'RECEIVE_CREATE_CUSTOM_STAGE_ERROR';
export const REQUEST_UPDATE_STAGE = 'REQUEST_UPDATE_STAGE'; export const REQUEST_UPDATE_STAGE = 'REQUEST_UPDATE_STAGE';
export const RECEIVE_UPDATE_STAGE_RESPONSE = 'RECEIVE_UPDATE_STAGE_RESPONSE'; export const RECEIVE_UPDATE_STAGE_SUCCESS = 'RECEIVE_UPDATE_STAGE_SUCCESS';
export const RECEIVE_UPDATE_STAGE_ERROR = 'RECEIVE_UPDATE_STAGE_ERROR';
export const REQUEST_REMOVE_STAGE = 'REQUEST_REMOVE_STAGE'; export const REQUEST_REMOVE_STAGE = 'REQUEST_REMOVE_STAGE';
export const RECEIVE_REMOVE_STAGE_RESPONSE = 'RECEIVE_REMOVE_STAGE_RESPONSE'; export const RECEIVE_REMOVE_STAGE_RESPONSE = 'RECEIVE_REMOVE_STAGE_RESPONSE';
......
...@@ -96,18 +96,24 @@ export default { ...@@ -96,18 +96,24 @@ export default {
}, },
[types.SHOW_CUSTOM_STAGE_FORM](state) { [types.SHOW_CUSTOM_STAGE_FORM](state) {
state.isCreatingCustomStage = true; state.isCreatingCustomStage = true;
state.customStageFormInitData = {}; state.isEditingCustomStage = false;
state.customStageFormInitialData = null;
state.customStageFormErrors = null;
}, },
[types.EDIT_CUSTOM_STAGE](state) { [types.SHOW_EDIT_CUSTOM_STAGE_FORM](state, initialData) {
state.isEditingCustomStage = true; state.isEditingCustomStage = true;
state.isCreatingCustomStage = false;
state.customStageFormInitialData = initialData;
state.customStageFormErrors = null;
}, },
[types.HIDE_CUSTOM_STAGE_FORM](state) { [types.HIDE_CUSTOM_STAGE_FORM](state) {
state.isEditingCustomStage = false; state.isEditingCustomStage = false;
state.isCreatingCustomStage = false; state.isCreatingCustomStage = false;
state.customStageFormInitData = {}; state.customStageFormInitialData = null;
state.customStageFormErrors = null;
}, },
[types.SHOW_CUSTOM_STAGE_FORM](state) { [types.CLEAR_CUSTOM_STAGE_FORM_ERRORS](state) {
state.isCreatingCustomStage = true; state.customStageFormErrors = null;
}, },
[types.RECEIVE_SUMMARY_DATA_ERROR](state) { [types.RECEIVE_SUMMARY_DATA_ERROR](state) {
state.summary = []; state.summary = [];
...@@ -152,16 +158,34 @@ export default { ...@@ -152,16 +158,34 @@ export default {
}, },
[types.REQUEST_CREATE_CUSTOM_STAGE](state) { [types.REQUEST_CREATE_CUSTOM_STAGE](state) {
state.isSavingCustomStage = true; state.isSavingCustomStage = true;
state.customStageFormErrors = {};
},
[types.RECEIVE_CREATE_CUSTOM_STAGE_ERROR](state, { errors = null } = {}) {
state.isSavingCustomStage = false;
state.customStageFormErrors = convertObjectPropsToCamelCase(errors, { deep: true });
}, },
[types.RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE](state) { [types.RECEIVE_CREATE_CUSTOM_STAGE_SUCCESS](state) {
state.isSavingCustomStage = false; state.isSavingCustomStage = false;
state.customStageFormErrors = null;
state.customStageFormInitialData = null;
}, },
[types.REQUEST_UPDATE_STAGE](state) { [types.REQUEST_UPDATE_STAGE](state) {
state.isLoading = true; state.isLoading = true;
state.isSavingCustomStage = true;
state.customStageFormErrors = null;
},
[types.RECEIVE_UPDATE_STAGE_SUCCESS](state) {
state.isLoading = false;
state.isSavingCustomStage = false;
state.isEditingCustomStage = false;
state.customStageFormErrors = null;
state.customStageFormInitialData = null;
}, },
[types.RECEIVE_UPDATE_STAGE_RESPONSE](state) { [types.RECEIVE_UPDATE_STAGE_ERROR](state, { errors = null, data } = {}) {
state.isLoading = false; state.isLoading = false;
state.isSavingCustomStage = false; state.isSavingCustomStage = false;
state.customStageFormErrors = convertObjectPropsToCamelCase(errors, { deep: true });
state.customStageFormInitialData = convertObjectPropsToCamelCase(data, { deep: true });
}, },
[types.REQUEST_REMOVE_STAGE](state) { [types.REQUEST_REMOVE_STAGE](state) {
state.isLoading = true; state.isLoading = true;
......
...@@ -31,6 +31,9 @@ export default () => ({ ...@@ -31,6 +31,9 @@ export default () => ({
medians: {}, medians: {},
customStageFormEvents: [], customStageFormEvents: [],
customStageFormErrors: null,
customStageFormInitialData: null,
tasksByType: { tasksByType: {
subject: TASKS_BY_TYPE_SUBJECT_ISSUE, subject: TASKS_BY_TYPE_SUBJECT_ISSUE,
labelIds: [], labelIds: [],
......
...@@ -120,11 +120,11 @@ module Analytics ...@@ -120,11 +120,11 @@ module Analytics
end end
def update_params def update_params
params.permit(:name, :start_event_identifier, :end_event_identifier, :id, :move_after_id, :move_before_id, :hidden) params.permit(:name, :start_event_identifier, :end_event_identifier, :id, :move_after_id, :move_before_id, :hidden, :start_event_label_id, :end_event_label_id)
end end
def create_params def create_params
params.permit(:name, :start_event_identifier, :end_event_identifier) params.permit(:name, :start_event_identifier, :end_event_identifier, :start_event_label_id, :end_event_label_id)
end end
def delete_params def delete_params
......
...@@ -4,13 +4,15 @@ require 'spec_helper' ...@@ -4,13 +4,15 @@ require 'spec_helper'
describe 'Group Value Stream Analytics', :js do describe 'Group Value Stream Analytics', :js do
let!(:user) { create(:user) } let!(:user) { create(:user) }
let!(:group) { create(:group, name: "CA-test-group") } let!(:group) { create(:group, name: "CA-test-group") }
let!(:group2) { create(:group, name: "CA-bad-test-group") }
let!(:project) { create(:project, :repository, namespace: group, group: group, name: "Cool fun project") } let!(:project) { create(:project, :repository, namespace: group, group: group, name: "Cool fun project") }
let!(:label) { create(:group_label, group: group) }
let!(:label2) { create(:group_label, group: group) }
let!(:label3) { create(:group_label, group: group2) }
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(: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(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) }
let(:label) { create(:group_label, group: group) }
let(:label2) { create(:group_label, group: group) }
stage_nav_selector = '.stage-nav' stage_nav_selector = '.stage-nav'
...@@ -21,6 +23,9 @@ describe 'Group Value Stream Analytics', :js do ...@@ -21,6 +23,9 @@ describe 'Group Value Stream Analytics', :js do
before do before do
stub_licensed_features(cycle_analytics_for_groups: true) stub_licensed_features(cycle_analytics_for_groups: true)
# chart returns an error since theres no data
stub_feature_flags(Gitlab::Analytics::TASKS_BY_TYPE_CHART_FEATURE_FLAG => false)
group.add_owner(user) group.add_owner(user)
project.add_maintainer(user) project.add_maintainer(user)
...@@ -224,6 +229,7 @@ describe 'Group Value Stream Analytics', :js do ...@@ -224,6 +229,7 @@ describe 'Group Value Stream Analytics', :js do
context 'enabled' do context 'enabled' do
before do before do
stub_licensed_features(cycle_analytics_for_groups: true, type_of_work_analytics: true) stub_licensed_features(cycle_analytics_for_groups: true, type_of_work_analytics: true)
stub_feature_flags(Gitlab::Analytics::TASKS_BY_TYPE_CHART_FEATURE_FLAG => true)
sign_in(user) sign_in(user)
end end
...@@ -287,8 +293,11 @@ describe 'Group Value Stream Analytics', :js do ...@@ -287,8 +293,11 @@ describe 'Group Value Stream Analytics', :js do
describe 'Customizable cycle analytics', :js do describe 'Customizable cycle analytics', :js do
custom_stage_name = "Cool beans" custom_stage_name = "Cool beans"
custom_stage_with_labels_name = "Cool beans - now with labels"
start_event_identifier = :merge_request_created start_event_identifier = :merge_request_created
end_event_identifier = :merge_request_merged end_event_identifier = :merge_request_merged
start_label_event = :issue_label_added
stop_label_event = :issue_label_removed
let(:button_class) { '.js-add-stage-button' } let(:button_class) { '.js-add-stage-button' }
let(:params) { { name: custom_stage_name, start_event_identifier: start_event_identifier, end_event_identifier: end_event_identifier } } let(:params) { { name: custom_stage_name, start_event_identifier: start_event_identifier, end_event_identifier: end_event_identifier } }
...@@ -309,6 +318,15 @@ describe 'Group Value Stream Analytics', :js do ...@@ -309,6 +318,15 @@ describe 'Group Value Stream Analytics', :js do
page.find("select[name='#{name}']").all(elem)[index].select_option page.find("select[name='#{name}']").all(elem)[index].select_option
end end
def select_dropdown_option_by_value(name, value, elem = "option")
page.find("select[name='#{name}']").find("#{elem}[value=#{value}]").select_option
end
def select_dropdown_label(field, index = 2)
page.find("[name=#{field}] .dropdown-toggle").click
page.find("[name=#{field}] .dropdown-menu").all('.dropdown-item')[index].click
end
context 'enabled' do context 'enabled' do
before do before do
select_group select_group
...@@ -340,10 +358,6 @@ describe 'Group Value Stream Analytics', :js do ...@@ -340,10 +358,6 @@ describe 'Group Value Stream Analytics', :js do
context 'Custom stage form' do context 'Custom stage form' do
let(:show_form_button_class) { '.js-add-stage-button' } let(:show_form_button_class) { '.js-add-stage-button' }
def select_dropdown_option(name, elem = "option", index = 1)
page.find("select[name='#{name}']").all(elem)[index].select_option
end
before do before do
select_group select_group
...@@ -357,6 +371,39 @@ describe 'Group Value Stream Analytics', :js do ...@@ -357,6 +371,39 @@ describe 'Group Value Stream Analytics', :js do
end end
end end
shared_examples 'submits the form successfully' do |stage_name|
it 'submit button is enabled' do
expect(page).to have_button('Add stage', disabled: false)
end
it 'submit button is disabled if the start event changes' do
select_dropdown_option 'custom-stage-start-event', 'option', 2
expect(page).to have_button('Add stage', disabled: true)
end
it 'the custom stage is saved' do
click_button 'Add stage'
expect(page).to have_selector('.stage-nav-item', text: stage_name)
end
it 'a confirmation message is displayed' do
fill_in 'custom-stage-name', with: stage_name
click_button 'Add stage'
expect(page.find('.flash-notice')).to have_text("Your custom stage '#{stage_name}' was created")
end
it 'with a default name' do
name = 'issue'
fill_in 'custom-stage-name', with: name
click_button 'Add stage'
expect(page.find('.flash-alert')).to have_text("'#{name}' stage already exists")
end
end
context 'with all required fields set' do context 'with all required fields set' do
before do before do
fill_in 'custom-stage-name', with: custom_stage_name fill_in 'custom-stage-name', with: custom_stage_name
...@@ -364,44 +411,48 @@ describe 'Group Value Stream Analytics', :js do ...@@ -364,44 +411,48 @@ describe 'Group Value Stream Analytics', :js do
select_dropdown_option 'custom-stage-stop-event' select_dropdown_option 'custom-stage-stop-event'
end end
it 'submit button is enabled' do it 'does not have label dropdowns' do
expect(page).to have_button('Add stage', disabled: false) expect(page).not_to have_content('Start event label')
expect(page).not_to have_content('Stop event label')
end end
it 'submit button is disabled if the start event changes' do it_behaves_like 'submits the form successfully', custom_stage_name
select_dropdown_option 'custom-stage-start-event', 'option', 2 end
expect(page).to have_button('Add stage', disabled: true) context 'with label based stages selected' do
before do
fill_in 'custom-stage-name', with: custom_stage_with_labels_name
select_dropdown_option_by_value 'custom-stage-start-event', start_label_event
select_dropdown_option_by_value 'custom-stage-stop-event', stop_label_event
end end
it 'an error message is displayed if the start event is changed' do it 'has label dropdowns' do
select_dropdown_option 'custom-stage-start-event', 'option', 2 expect(page).to have_content('Start event label')
expect(page).to have_content('Stop event label')
end
expect(page).to have_text 'Start event changed, please select a valid stop event' it 'submit button is disabled' do
expect(page).to have_button('Add stage', disabled: true)
end end
context 'submit button is clicked' do it 'does not contain labels from outside the group' do
it 'the custom stage is saved' do field = 'custom-stage-start-event-label'
click_button 'Add stage' page.find("[name=#{field}] .dropdown-toggle").click
expect(page).to have_selector('.stage-nav-item', text: custom_stage_name) menu = page.find("[name=#{field}] .dropdown-menu")
end
it 'a confirmation message is displayed' do expect(menu).not_to have_content(label3.name)
name = 'cool beans number 2' expect(menu).to have_content(label.name)
fill_in 'custom-stage-name', with: name expect(menu).to have_content(label2.name)
click_button 'Add stage' end
expect(page.find('.flash-notice')).to have_text("Your custom stage '#{name}' was created") context 'with all required fields set' do
before do
select_dropdown_label 'custom-stage-start-event-label', 1
select_dropdown_label 'custom-stage-stop-event-label', 2
end end
it 'with a default name' do it_behaves_like 'submits the form successfully', custom_stage_with_labels_name
name = 'issue'
fill_in 'custom-stage-name', with: name
click_button 'Add stage'
expect(page.find('.flash-alert')).to have_text("'#{name}' stage already exists")
end
end end
end end
end end
......
...@@ -6,7 +6,7 @@ exports[`CustomStageForm Editing a custom stage isSavingCustomStage=true display ...@@ -6,7 +6,7 @@ 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`] = ` exports[`CustomStageForm 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__123\\"> "<select name=\\"custom-stage-start-event\\" required=\\"required\\" aria-required=\\"true\\" class=\\"gl-form-select custom-select\\" id=\\"__BVID__123\\">
<option value=\\"\\">Select start event</option> <option value=\\"\\">Select start event</option>
<option value=\\"issue_created\\">Issue created</option> <option value=\\"issue_created\\">Issue created</option>
...@@ -29,7 +29,7 @@ exports[`CustomStageForm Empty form Start event with events does not select even ...@@ -29,7 +29,7 @@ exports[`CustomStageForm Empty form Start event with events does not select even
</select>" </select>"
`; `;
exports[`CustomStageForm Empty form Start event with events selects events with canBeStartEvent=true for the start events dropdown 1`] = ` exports[`CustomStageForm 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__95\\"> "<select name=\\"custom-stage-start-event\\" required=\\"required\\" aria-required=\\"true\\" class=\\"gl-form-select custom-select\\" id=\\"__BVID__95\\">
<option value=\\"\\">Select start event</option> <option value=\\"\\">Select start event</option>
<option value=\\"issue_created\\">Issue created</option> <option value=\\"issue_created\\">Issue created</option>
...@@ -52,7 +52,7 @@ exports[`CustomStageForm Empty form Start event with events selects events with ...@@ -52,7 +52,7 @@ exports[`CustomStageForm Empty form Start event with events selects events with
</select>" </select>"
`; `;
exports[`CustomStageForm Empty form isSavingCustomStage=true displays a loading icon 1`] = ` exports[`CustomStageForm 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
</button>" </button>"
......
...@@ -118,6 +118,13 @@ export const labelStopEvent = customStageLabelEvents.find( ...@@ -118,6 +118,13 @@ export const labelStopEvent = customStageLabelEvents.find(
ev => ev.identifier === labelStartEvent.allowedEndEvents[0], ev => ev.identifier === labelStartEvent.allowedEndEvents[0],
); );
export const rawCustomStageFormErrors = {
name: ['is reserved', 'cant be blank'],
start_event_identifier: ['cant be blank'],
};
export const customStageFormErrors = convertObjectPropsToCamelCase(rawCustomStageFormErrors);
const dateRange = getDatesInRange(startDate, endDate, toYmd); const dateRange = getDatesInRange(startDate, endDate, toYmd);
export const tasksByTypeData = getJSONFixture('analytics/type_of_work/tasks_by_type.json').map( export const tasksByTypeData = getJSONFixture('analytics/type_of_work/tasks_by_type.json').map(
......
...@@ -6,6 +6,7 @@ import * as actions from 'ee/analytics/cycle_analytics/store/actions'; ...@@ -6,6 +6,7 @@ import * as actions from 'ee/analytics/cycle_analytics/store/actions';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types'; import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import { TASKS_BY_TYPE_FILTERS } from 'ee/analytics/cycle_analytics/constants'; import { TASKS_BY_TYPE_FILTERS } from 'ee/analytics/cycle_analytics/constants';
import createFlash from '~/flash'; import createFlash from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status';
import { import {
group, group,
summaryData, summaryData,
...@@ -21,7 +22,7 @@ import { ...@@ -21,7 +22,7 @@ import {
} from '../mock_data'; } from '../mock_data';
const stageData = { events: [] }; const stageData = { events: [] };
const error = new Error('Request failed with status code 404'); const error = new Error(`Request failed with status code ${httpStatusCodes.NOT_FOUND}`);
const flashErrorMessage = 'There was an error while fetching value stream analytics data.'; const flashErrorMessage = 'There was an error while fetching value stream analytics data.';
const selectedGroup = { fullPath: group.path }; const selectedGroup = { fullPath: group.path };
const [selectedStage] = stages; const [selectedStage] = stages;
...@@ -125,7 +126,7 @@ describe('Cycle analytics actions', () => { ...@@ -125,7 +126,7 @@ describe('Cycle analytics actions', () => {
describe('with a failing request', () => { describe('with a failing request', () => {
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
mock.onGet(endpoints.stageData).replyOnce(404, { error }); mock.onGet(endpoints.stageData).replyOnce(httpStatusCodes.NOT_FOUND, { error });
}); });
it('dispatches receiveStageDataError on error', done => { it('dispatches receiveStageDataError on error', done => {
...@@ -620,12 +621,13 @@ describe('Cycle analytics actions', () => { ...@@ -620,12 +621,13 @@ describe('Cycle analytics actions', () => {
beforeEach(() => { beforeEach(() => {
setFixtures('<div class="flash-container"></div>'); setFixtures('<div class="flash-container"></div>');
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
mock.onPut(stageEndpoint({ stageId })).replyOnce(404); mock.onPut(stageEndpoint({ stageId })).replyOnce(httpStatusCodes.NOT_FOUND);
}); });
it('dispatches receiveUpdateStageError', done => { it('dispatches receiveUpdateStageError', done => {
const data = { const data = {
id: stageId, id: stageId,
name: 'issue',
...payload, ...payload,
}; };
testAction( testAction(
...@@ -637,7 +639,10 @@ describe('Cycle analytics actions', () => { ...@@ -637,7 +639,10 @@ describe('Cycle analytics actions', () => {
{ type: 'requestUpdateStage' }, { type: 'requestUpdateStage' },
{ {
type: 'receiveUpdateStageError', type: 'receiveUpdateStageError',
payload: { error, data }, payload: {
status: httpStatusCodes.NOT_FOUND,
data,
},
}, },
], ],
done, done,
...@@ -651,13 +656,9 @@ describe('Cycle analytics actions', () => { ...@@ -651,13 +656,9 @@ describe('Cycle analytics actions', () => {
state, state,
}, },
{ {
error: { status: httpStatusCodes.UNPROCESSABLE_ENTITY,
response: { responseData: {
status: 422, errors: { name: ['is reserved'] },
data: {
errors: { name: ['is reserved'] },
},
},
}, },
data: { data: {
name: stageId, name: stageId,
...@@ -675,13 +676,60 @@ describe('Cycle analytics actions', () => { ...@@ -675,13 +676,60 @@ describe('Cycle analytics actions', () => {
commit: () => {}, commit: () => {},
state, state,
}, },
{}, { status: httpStatusCodes.BAD_REQUEST },
); );
shouldFlashAMessage('There was a problem saving your custom stage, please try again'); shouldFlashAMessage('There was a problem saving your custom stage, please try again');
done(); done();
}); });
}); });
describe('receiveUpdateStageSuccess', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
const response = {
title: 'NEW - COOL',
};
it('will dispatch fetchGroupStagesAndEvents and fetchSummaryData', () =>
testAction(
actions.receiveUpdateStageSuccess,
response,
state,
[{ type: types.RECEIVE_UPDATE_STAGE_SUCCESS }],
[{ type: 'fetchGroupStagesAndEvents' }, { type: 'setSelectedStage', payload: response }],
));
it('will flash a success message', () =>
actions
.receiveUpdateStageSuccess(
{
dispatch: () => {},
commit: () => {},
},
response,
)
.then(() => {
shouldFlashAMessage('Stage data updated');
}));
describe('with an error', () => {
it('will flash an error message', () =>
actions
.receiveUpdateStageSuccess(
{
dispatch: () => Promise.reject(),
commit: () => {},
},
response,
)
.then(() => {
shouldFlashAMessage('There was a problem refreshing the data, please try again');
}));
});
});
}); });
describe('removeStage', () => { describe('removeStage', () => {
...@@ -712,7 +760,7 @@ describe('Cycle analytics actions', () => { ...@@ -712,7 +760,7 @@ describe('Cycle analytics actions', () => {
describe('with a failed request', () => { describe('with a failed request', () => {
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
mock.onDelete(stageEndpoint({ stageId })).replyOnce(404); mock.onDelete(stageEndpoint({ stageId })).replyOnce(httpStatusCodes.NOT_FOUND);
}); });
it('dispatches receiveRemoveStageError', done => { it('dispatches receiveRemoveStageError', done => {
...@@ -1189,7 +1237,7 @@ describe('Cycle analytics actions', () => { ...@@ -1189,7 +1237,7 @@ describe('Cycle analytics actions', () => {
describe('with a failing request', () => { describe('with a failing request', () => {
beforeEach(() => { beforeEach(() => {
mock.onGet(endpoints.stageMedian).reply(404, { error }); mock.onGet(endpoints.stageMedian).reply(httpStatusCodes.NOT_FOUND, { error });
}); });
it('will dispatch receiveStageMedianValuesError', done => { it('will dispatch receiveStageMedianValuesError', done => {
...@@ -1276,4 +1324,156 @@ describe('Cycle analytics actions', () => { ...@@ -1276,4 +1324,156 @@ describe('Cycle analytics actions', () => {
); );
}); });
}); });
describe('createCustomStage', () => {
describe('with valid data', () => {
const customStageData = {
startEventIdentifier: 'start_event',
endEventIdentifier: 'end_event',
name: 'cool-new-stage',
};
beforeEach(() => {
state = { ...state, selectedGroup };
mock.onPost(endpoints.baseStagesEndpointstageData).reply(201, customStageData);
});
it(`dispatches the 'receiveCreateCustomStageSuccess' action`, () =>
testAction(
actions.createCustomStage,
customStageData,
state,
[],
[
{ type: 'requestCreateCustomStage' },
{
type: 'receiveCreateCustomStageSuccess',
payload: { data: customStageData, status: 201 },
},
],
));
});
describe('with errors', () => {
const message = 'failed';
const errors = {
endEventIdentifier: ['Cant be blank'],
};
const customStageData = {
startEventIdentifier: 'start_event',
endEventIdentifier: '',
name: 'cool-new-stage',
};
beforeEach(() => {
state = { ...state, selectedGroup };
mock
.onPost(endpoints.baseStagesEndpointstageData)
.reply(httpStatusCodes.UNPROCESSABLE_ENTITY, {
message,
errors,
});
});
it(`dispatches the 'receiveCreateCustomStageError' action`, () =>
testAction(
actions.createCustomStage,
customStageData,
state,
[],
[
{ type: 'requestCreateCustomStage' },
{
type: 'receiveCreateCustomStageError',
payload: {
data: customStageData,
errors,
message,
status: httpStatusCodes.UNPROCESSABLE_ENTITY,
},
},
],
));
});
});
describe('receiveCreateCustomStageError', () => {
const response = {
data: { name: 'uh oh' },
};
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it('will commit the RECEIVE_CREATE_CUSTOM_STAGE_ERROR mutation', () =>
testAction(actions.receiveCreateCustomStageError, response, state, [
{ type: types.RECEIVE_CREATE_CUSTOM_STAGE_ERROR, payload: { errors: {} } },
]));
it('will flash an error message', done => {
actions.receiveCreateCustomStageError(
{
commit: () => {},
},
response,
);
shouldFlashAMessage('There was a problem saving your custom stage, please try again');
done();
});
describe('with a stage name error', () => {
it('will flash an error message', done => {
actions.receiveCreateCustomStageError(
{
commit: () => {},
},
{
...response,
status: httpStatusCodes.UNPROCESSABLE_ENTITY,
errors: { name: ['is reserved'] },
},
);
shouldFlashAMessage("'uh oh' stage already exists");
done();
});
});
});
describe('receiveCreateCustomStageSuccess', () => {
const response = {
data: {
title: 'COOL',
},
};
it('will dispatch fetchGroupStagesAndEvents and fetchSummaryData', () =>
testAction(
actions.receiveCreateCustomStageSuccess,
response,
state,
[{ type: types.RECEIVE_CREATE_CUSTOM_STAGE_SUCCESS }],
[{ type: 'fetchGroupStagesAndEvents' }, { type: 'fetchSummaryData' }],
));
describe('with an error', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it('will flash an error message', () =>
actions
.receiveCreateCustomStageSuccess(
{
dispatch: () => Promise.reject(),
commit: () => {},
},
response,
)
.then(() => {
shouldFlashAMessage('There was a problem refreshing the data, please try again');
}));
});
});
}); });
...@@ -37,8 +37,15 @@ describe('Cycle analytics mutations', () => { ...@@ -37,8 +37,15 @@ describe('Cycle analytics mutations', () => {
it.each` it.each`
mutation | stateKey | value mutation | stateKey | value
${types.HIDE_CUSTOM_STAGE_FORM} | ${'isCreatingCustomStage'} | ${false} ${types.HIDE_CUSTOM_STAGE_FORM} | ${'isCreatingCustomStage'} | ${false}
${types.HIDE_CUSTOM_STAGE_FORM} | ${'isEditingCustomStage'} | ${false}
${types.HIDE_CUSTOM_STAGE_FORM} | ${'customStageFormErrors'} | ${null}
${types.HIDE_CUSTOM_STAGE_FORM} | ${'customStageFormInitialData'} | ${null}
${types.SHOW_CUSTOM_STAGE_FORM} | ${'isCreatingCustomStage'} | ${true} ${types.SHOW_CUSTOM_STAGE_FORM} | ${'isCreatingCustomStage'} | ${true}
${types.EDIT_CUSTOM_STAGE} | ${'isEditingCustomStage'} | ${true} ${types.SHOW_CUSTOM_STAGE_FORM} | ${'isEditingCustomStage'} | ${false}
${types.SHOW_CUSTOM_STAGE_FORM} | ${'customStageFormErrors'} | ${null}
${types.SHOW_EDIT_CUSTOM_STAGE_FORM} | ${'isEditingCustomStage'} | ${true}
${types.SHOW_EDIT_CUSTOM_STAGE_FORM} | ${'isCreatingCustomStage'} | ${false}
${types.SHOW_EDIT_CUSTOM_STAGE_FORM} | ${'customStageFormErrors'} | ${null}
${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true} ${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true} ${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false} ${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false}
...@@ -52,11 +59,20 @@ describe('Cycle analytics mutations', () => { ...@@ -52,11 +59,20 @@ describe('Cycle analytics mutations', () => {
${types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR} | ${'customStageFormEvents'} | ${[]} ${types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR} | ${'customStageFormEvents'} | ${[]}
${types.REQUEST_GROUP_STAGES_AND_EVENTS} | ${'customStageFormEvents'} | ${[]} ${types.REQUEST_GROUP_STAGES_AND_EVENTS} | ${'customStageFormEvents'} | ${[]}
${types.REQUEST_CREATE_CUSTOM_STAGE} | ${'isSavingCustomStage'} | ${true} ${types.REQUEST_CREATE_CUSTOM_STAGE} | ${'isSavingCustomStage'} | ${true}
${types.RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE} | ${'isSavingCustomStage'} | ${false} ${types.RECEIVE_CREATE_CUSTOM_STAGE_SUCCESS} | ${'isSavingCustomStage'} | ${false}
${types.RECEIVE_CREATE_CUSTOM_STAGE_ERROR} | ${'isSavingCustomStage'} | ${false}
${types.RECEIVE_CREATE_CUSTOM_STAGE_ERROR} | ${'customStageFormErrors'} | ${{}}
${types.REQUEST_TASKS_BY_TYPE_DATA} | ${'isLoadingTasksByTypeChart'} | ${true} ${types.REQUEST_TASKS_BY_TYPE_DATA} | ${'isLoadingTasksByTypeChart'} | ${true}
${types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR} | ${'isLoadingTasksByTypeChart'} | ${false} ${types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR} | ${'isLoadingTasksByTypeChart'} | ${false}
${types.REQUEST_UPDATE_STAGE} | ${'isLoading'} | ${true} ${types.REQUEST_UPDATE_STAGE} | ${'isLoading'} | ${true}
${types.RECEIVE_UPDATE_STAGE_RESPONSE} | ${'isLoading'} | ${false} ${types.REQUEST_UPDATE_STAGE} | ${'isSavingCustomStage'} | ${true}
${types.REQUEST_UPDATE_STAGE} | ${'customStageFormErrors'} | ${null}
${types.RECEIVE_UPDATE_STAGE_SUCCESS} | ${'isLoading'} | ${false}
${types.RECEIVE_UPDATE_STAGE_SUCCESS} | ${'isSavingCustomStage'} | ${false}
${types.RECEIVE_UPDATE_STAGE_SUCCESS} | ${'isEditingCustomStage'} | ${false}
${types.RECEIVE_UPDATE_STAGE_SUCCESS} | ${'customStageFormErrors'} | ${null}
${types.RECEIVE_UPDATE_STAGE_ERROR} | ${'isLoading'} | ${false}
${types.RECEIVE_UPDATE_STAGE_ERROR} | ${'isSavingCustomStage'} | ${false}
${types.REQUEST_REMOVE_STAGE} | ${'isLoading'} | ${true} ${types.REQUEST_REMOVE_STAGE} | ${'isLoading'} | ${true}
${types.RECEIVE_REMOVE_STAGE_RESPONSE} | ${'isLoading'} | ${false} ${types.RECEIVE_REMOVE_STAGE_RESPONSE} | ${'isLoading'} | ${false}
${types.REQUEST_DURATION_DATA} | ${'isLoadingDurationChart'} | ${true} ${types.REQUEST_DURATION_DATA} | ${'isLoadingDurationChart'} | ${true}
...@@ -116,6 +132,18 @@ describe('Cycle analytics mutations', () => { ...@@ -116,6 +132,18 @@ describe('Cycle analytics mutations', () => {
}); });
}); });
describe(`types.RECEIVE_UPDATE_STAGE_ERROR`, () => {
const mockFormError = { errors: { start_identifier: ['Cant be blank'] } };
it('will set customStageFormErrors', () => {
state = {};
mutations[types.RECEIVE_UPDATE_STAGE_ERROR](state, mockFormError);
expect(state.customStageFormErrors).toEqual(
convertObjectPropsToCamelCase(mockFormError.errors),
);
});
});
describe.each` describe.each`
mutation | value mutation | value
${types.REQUEST_GROUP_LABELS} | ${[]} ${types.REQUEST_GROUP_LABELS} | ${[]}
......
...@@ -502,6 +502,9 @@ msgstr "" ...@@ -502,6 +502,9 @@ msgstr ""
msgid "'%{level}' is not a valid visibility level" msgid "'%{level}' is not a valid visibility level"
msgstr "" msgstr ""
msgid "'%{name}' stage already exists"
msgstr ""
msgid "'%{source}' is not a import source" msgid "'%{source}' is not a import source"
msgstr "" msgstr ""
...@@ -19338,6 +19341,9 @@ msgstr "" ...@@ -19338,6 +19341,9 @@ msgstr ""
msgid "There was a problem communicating with your device." msgid "There was a problem communicating with your device."
msgstr "" msgstr ""
msgid "There was a problem refreshing the data, please try again"
msgstr ""
msgid "There was a problem saving your custom stage, please try again" msgid "There was a problem saving your custom stage, please try again"
msgstr "" msgstr ""
...@@ -22394,6 +22400,9 @@ msgstr "" ...@@ -22394,6 +22400,9 @@ msgstr ""
msgid "Your comment could not be updated! Please check your network connection and try again." msgid "Your comment could not be updated! Please check your network connection and try again."
msgstr "" msgstr ""
msgid "Your custom stage '%{title}' was created"
msgstr ""
msgid "Your dashboard has been copied. You can %{web_ide_link_start}edit it here%{web_ide_link_end}." msgid "Your dashboard has been copied. You can %{web_ide_link_start}edit it here%{web_ide_link_end}."
msgstr "" msgstr ""
......
...@@ -123,7 +123,7 @@ RSpec.shared_examples 'cycle analytics label based stage' do ...@@ -123,7 +123,7 @@ RSpec.shared_examples 'cycle analytics label based stage' do
}) })
expect(stage).to be_invalid expect(stage).to be_invalid
expect(stage.errors[:start_event_label]).to include("can't be blank") expect(stage.errors[:start_event_label_id]).to include("can't be blank")
end end
it 'returns validation error when `end_event_label_id` is missing' do it 'returns validation error when `end_event_label_id` is missing' do
...@@ -135,7 +135,7 @@ RSpec.shared_examples 'cycle analytics label based stage' do ...@@ -135,7 +135,7 @@ RSpec.shared_examples 'cycle analytics label based stage' do
}) })
expect(stage).to be_invalid expect(stage).to be_invalid
expect(stage.errors[:end_event_label]).to include("can't be blank") expect(stage.errors[:end_event_label_id]).to include("can't be blank")
end end
end end
...@@ -145,7 +145,7 @@ RSpec.shared_examples 'cycle analytics label based stage' do ...@@ -145,7 +145,7 @@ RSpec.shared_examples 'cycle analytics label based stage' do
name: 'My Stage', name: 'My Stage',
parent: parent, parent: parent,
start_event_identifier: :issue_label_added, start_event_identifier: :issue_label_added,
start_event_label: group_label, start_event_label_id: group_label.id,
end_event_identifier: :issue_closed end_event_identifier: :issue_closed
}) })
...@@ -159,7 +159,7 @@ RSpec.shared_examples 'cycle analytics label based stage' do ...@@ -159,7 +159,7 @@ RSpec.shared_examples 'cycle analytics label based stage' do
name: 'My Stage', name: 'My Stage',
parent: parent_in_subgroup, parent: parent_in_subgroup,
start_event_identifier: :issue_label_added, start_event_identifier: :issue_label_added,
start_event_label: group_label, start_event_label_id: group_label.id,
end_event_identifier: :issue_closed end_event_identifier: :issue_closed
}) })
...@@ -170,30 +170,30 @@ RSpec.shared_examples 'cycle analytics label based stage' do ...@@ -170,30 +170,30 @@ RSpec.shared_examples 'cycle analytics label based stage' do
context 'when label is defined for a different group' do context 'when label is defined for a different group' do
let(:error_message) { s_('CycleAnalyticsStage|is not available for the selected group') } let(:error_message) { s_('CycleAnalyticsStage|is not available for the selected group') }
it 'returns validation for `start_event_label`' do it 'returns validation for `start_event_label_id`' do
stage = described_class.new({ stage = described_class.new({
name: 'My Stage', name: 'My Stage',
parent: parent_outside_of_group_label_scope, parent: parent_outside_of_group_label_scope,
start_event_identifier: :issue_label_added, start_event_identifier: :issue_label_added,
start_event_label: group_label, start_event_label_id: group_label.id,
end_event_identifier: :issue_closed end_event_identifier: :issue_closed
}) })
expect(stage).to be_invalid expect(stage).to be_invalid
expect(stage.errors[:start_event_label]).to include(error_message) expect(stage.errors[:start_event_label_id]).to include(error_message)
end end
it 'returns validation for `end_event_label`' do it 'returns validation for `end_event_label_id`' do
stage = described_class.new({ stage = described_class.new({
name: 'My Stage', name: 'My Stage',
parent: parent_outside_of_group_label_scope, parent: parent_outside_of_group_label_scope,
start_event_identifier: :issue_closed, start_event_identifier: :issue_closed,
end_event_identifier: :issue_label_added, end_event_identifier: :issue_label_added,
end_event_label: group_label end_event_label_id: group_label.id
}) })
expect(stage).to be_invalid expect(stage).to be_invalid
expect(stage.errors[:end_event_label]).to include(error_message) expect(stage.errors[:end_event_label_id]).to include(error_message)
end end
end end
......
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