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