Commit 6fbee9fa authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Updated custom stage form specs

Updated label selector specs

Basic display form errors

Clear form errors after field update

Expose custom cycle analytics label ids

Updates validators for (start|end)_event_label_id

Exposes the start_event_label_id and the
end_event_label_id fields for the create and
update endpoints

Fix shared example specs
parent edece6f7
......@@ -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',
......@@ -143,6 +144,7 @@ export default {
'removeStage',
'setFeatureFlags',
'editCustomStage',
'clearCustomStageFormErrors',
'updateStage',
'setTasksByTypeFilters',
]),
......@@ -278,6 +280,8 @@ export default {
:is-editing-custom-stage="isEditingCustomStage"
:current-stage-events="currentStageEvents"
:custom-stage-form-events="customStageFormEvents"
:custom-stage-form-errors="customStageFormErrors"
@clearCustomStageFormErrors="clearCustomStageFormErrors"
:labels="labels"
:no-data-svg-path="noDataSvgPath"
:no-access-svg-path="noAccessSvgPath"
......
......@@ -55,6 +55,11 @@ export default {
required: false,
default: false,
},
errors: {
type: Object,
required: false,
default: null,
},
},
data() {
return {
......@@ -167,6 +172,15 @@ export default {
handleClearLabel(key) {
this.fields[key] = null;
},
isValid(key) {
return !this.isDirty || !this.errors || !this.errors[key];
},
fieldErrors(key) {
return !this.isValid(key) ? this.errors[key].join('\n') : null;
},
onUpdateFormField() {
if (this.errors) this.$emit('clearErrors');
},
},
};
</script>
......@@ -175,7 +189,11 @@ export default {
<div class="mb-1">
<h4>{{ formTitle }}</h4>
</div>
<gl-form-group :label="s__('CustomCycleAnalytics|Name')">
<gl-form-group
:label="s__('CustomCycleAnalytics|Name')"
:state="isValid('name')"
:invalid-feedback="fieldErrors('name')"
>
<gl-form-input
v-model="fields.name"
class="form-control"
......@@ -183,27 +201,38 @@ export default {
name="custom-stage-name"
:placeholder="s__('CustomCycleAnalytics|Enter a name for the stage')"
required
@change="onUpdateFormField"
/>
</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
:label="s__('CustomCycleAnalytics|Start event')"
:state="isValid('startEventIdentifier')"
:invalid-feedback="fieldErrors('startEventIdentifier')"
>
<gl-form-select
v-model="fields.startEventIdentifier"
name="custom-stage-start-event"
:required="true"
:options="startEventOptions"
@change="onUpdateFormField"
/>
</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
:label="s__('CustomCycleAnalytics|Start event label')"
:state="isValid('startEventLabelId')"
:invalid-feedback="fieldErrors('startEventLabelId')"
>
<labels-selector
:labels="labels"
:selected-label-id="fields.startEventLabelId"
name="custom-stage-start-event-label"
@selectLabel="handleSelectLabel('startEventLabelId', $event)"
@clearLabel="handleClearLabel('startEventLabelId')"
@change="onUpdateFormField"
/>
</gl-form-group>
</div>
......@@ -215,26 +244,34 @@ export default {
:description="
!hasStartEvent ? s__('CustomCycleAnalytics|Please select a start event first') : ''
"
:state="hasValidStartAndEndEventPair"
:invalid-feedback="endEventError"
:state="isValid('endEventIdentifier')"
:invalid-feedback="fieldErrors('endEventIdentifier')"
>
<!-- :state="hasValidStartAndEndEventPair"
:invalid-feedback="endEventError" -->
<gl-form-select
v-model="fields.endEventIdentifier"
name="custom-stage-stop-event"
:options="endEventOptions"
:required="true"
:disabled="!hasStartEvent"
@change="onUpdateFormField"
/>
</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
:label="s__('CustomCycleAnalytics|Stop event label')"
:state="isValid('endEventLabelId')"
:invalid-feedback="fieldErrors('endEventLabelId')"
>
<labels-selector
:labels="labels"
:selected-label-id="fields.endEventLabelId"
name="custom-stage-stop-event-label"
@selectLabel="handleSelectLabel('endEventLabelId', $event)"
@clearLabel="handleClearLabel('endEventLabelId')"
@change="onUpdateFormField"
/>
</gl-form-group>
</div>
......
......@@ -63,6 +63,11 @@ export default {
type: Array,
required: true,
},
customStageFormErrors: {
type: Object,
required: false,
default: null,
},
labels: {
type: Array,
required: true,
......@@ -209,9 +214,11 @@ export default {
:is-saving-custom-stage="isSavingCustomStage"
:initial-fields="customStageInitialData"
: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
......
......@@ -236,6 +236,9 @@ export const fetchGroupStagesAndEvents = ({ state, dispatch, getters }) => {
);
};
export const clearCustomStageFormErrors = ({ commit }) =>
commit(types.CLEAR_CUSTOM_STAGE_FORM_ERRORS);
export const requestCreateCustomStage = ({ commit }) => commit(types.REQUEST_CREATE_CUSTOM_STAGE);
export const receiveCreateCustomStageSuccess = ({ commit, dispatch }, { data: { title } }) => {
commit(types.RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE);
......@@ -244,19 +247,18 @@ export const receiveCreateCustomStageSuccess = ({ commit, dispatch }, { data: {
return dispatch('fetchGroupStagesAndEvents').then(() => dispatch('fetchSummaryData'));
};
export const receiveCreateCustomStageError = ({ commit }, { error, data }) => {
commit(types.RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE);
export const receiveCreateCustomStageError = ({ commit }, { status, message, errors, data }) => {
commit(types.RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE, { status, message, errors, data });
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 =
const flashMessage =
status !== httpStatus.UNPROCESSABLE_ENTITY
? __(`'${name}' stage already exists'`)
: __('There was a problem saving your custom stage, please try again');
createFlash(message);
createFlash(flashMessage);
};
export const createCustomStage = ({ dispatch, state }, data) => {
......@@ -267,7 +269,14 @@ export const createCustomStage = ({ dispatch, state }, data) => {
return Api.cycleAnalyticsCreateStage(fullPath, data)
.then(response => dispatch('receiveCreateCustomStageSuccess', response))
.catch(error => dispatch('receiveCreateCustomStageError', { error, data }));
.catch(({ response }) => {
const {
data: { message, errors },
status,
} = response;
dispatch('receiveCreateCustomStageError', { data, message, errors, status });
});
};
export const receiveTasksByTypeDataSuccess = ({ commit }, data) => {
......
......@@ -22,6 +22,7 @@ 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 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,7 +44,8 @@ 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';
......
......@@ -109,6 +109,9 @@ export default {
[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 = [];
},
......@@ -153,7 +156,11 @@ export default {
[types.REQUEST_CREATE_CUSTOM_STAGE](state) {
state.isSavingCustomStage = true;
},
[types.RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE](state) {
[types.RECEIVE_CREATE_CUSTOM_STAGE_ERROR](state, { errors = null }) {
state.isSavingCustomStage = false;
state.customStageFormErrors = errors;
},
[types.RECEIVE_CREATE_CUSTOM_STAGE_SUCCESS](state) {
state.isSavingCustomStage = false;
},
[types.REQUEST_UPDATE_STAGE](state) {
......
......@@ -31,6 +31,8 @@ export default () => ({
medians: {},
customStageFormEvents: [],
customStageFormErrors: 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
......
......@@ -5,12 +5,12 @@ describe 'Group Value Stream Analytics', :js do
let!(:user) { create(:user) }
let!(:group) { create(:group, name: "CA-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(: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 +21,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 +227,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 +291,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 +316,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')[2].click
end
context 'enabled' do
before do
select_group
......@@ -340,10 +356,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 +369,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
......@@ -380,28 +386,66 @@ describe 'Group Value Stream Analytics', :js do
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'
it 'the custom stage is saved' do
click_button 'Add stage'
expect(page).to have_selector('.stage-nav-item', text: custom_stage_name)
end
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
click_button 'Add stage'
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 '#{name}' was created")
end
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
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
it 'with a default name' do
name = 'issue'
fill_in 'custom-stage-name', with: name
click_button 'Add stage'
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
expect(page.find('.flash-alert')).to have_text("'#{name}' stage already exists")
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
context 'with all required fields set' do
before do
select_dropdown_label 'custom-stage-start-event-label', 2
select_dropdown_label 'custom-stage-stop-event-label', 3
end
it_behaves_like 'submits the form successfully', custom_stage_with_labels_name
end
end
end
......
......@@ -52,7 +52,9 @@ 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'} | ${{ errors: [] }}
${types.REQUEST_TASKS_BY_TYPE_DATA} | ${'isLoadingTasksByTypeChart'} | ${true}
${types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR} | ${'isLoadingTasksByTypeChart'} | ${false}
${types.REQUEST_UPDATE_STAGE} | ${'isLoading'} | ${true}
......
......@@ -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