Commit 59344b22 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch 'fix-inline-error-display-when-creating-custom-stage' into 'master'

Fix inline error display when creating a custom stage

See merge request gitlab-org/gitlab!25509
parents 7dd5296d 03092f3d
...@@ -13,7 +13,7 @@ import { ...@@ -13,7 +13,7 @@ import {
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import LabelsSelector from './labels_selector.vue'; import LabelsSelector from './labels_selector.vue';
import { STAGE_ACTIONS } from '../constants'; import { STAGE_ACTIONS, DEFAULT_STAGE_NAMES } from '../constants';
import { import {
isStartEvent, isStartEvent,
isLabelEvent, isLabelEvent,
...@@ -32,6 +32,28 @@ const defaultFields = { ...@@ -32,6 +32,28 @@ const defaultFields = {
endEventLabelId: null, endEventLabelId: null,
}; };
export const initializeFormData = ({ emptyFieldState, initialFields, errors }) => {
const defaultErrors = initialFields?.endEventIdentifier
? { ...emptyFieldState, endEventIdentifier: null }
: {
...emptyFieldState,
endEventIdentifier:
initialFields && !initialFields.startEventIdentifier
? [s__('CustomCycleAnalytics|Please select a start event first')]
: null,
};
return {
fields: {
...emptyFieldState,
...initialFields,
},
fieldErrors: {
...defaultErrors,
...errors,
},
};
};
export default { export default {
components: { components: {
GlFormGroup, GlFormGroup,
...@@ -70,24 +92,20 @@ export default { ...@@ -70,24 +92,20 @@ export default {
errors: { errors: {
type: Object, type: Object,
required: false, required: false,
default: () => ({}), default: null,
}, },
}, },
data() { data() {
const defaultErrors = this?.initialFields?.endEventIdentifier const { initialFields = {}, errors = null } = this;
? {} const { fields, fieldErrors } = initializeFormData({
: { endEventIdentifier: [s__('CustomCycleAnalytics|Please select a start event first')] }; emptyFieldState: defaultFields,
initialFields,
errors,
});
return { return {
labelEvents: getLabelEventsIdentifiers(this.events), labelEvents: getLabelEventsIdentifiers(this.events),
fields: { fields,
...defaultFields, fieldErrors,
...this.initialFields,
},
fieldErrors: {
...defaultFields,
...this.errors,
...defaultErrors,
},
}; };
}, },
computed: { computed: {
...@@ -114,8 +132,14 @@ export default { ...@@ -114,8 +132,14 @@ export default {
endEventRequiresLabel() { endEventRequiresLabel() {
return isLabelEvent(this.labelEvents, this.fields.endEventIdentifier); return isLabelEvent(this.labelEvents, this.fields.endEventIdentifier);
}, },
hasErrors() {
return (
this.eventMismatchError ||
Object.values(this.fieldErrors).some(errArray => errArray?.length)
);
},
isComplete() { isComplete() {
if (this.eventMismatchError) { if (this.hasErrors) {
return false; return false;
} }
const { const {
...@@ -171,18 +195,24 @@ export default { ...@@ -171,18 +195,24 @@ export default {
...defaultFields, ...defaultFields,
...newFields, ...newFields,
}; };
},
errors(newErrors) {
this.fieldErrors = { this.fieldErrors = {
...defaultFields, ...defaultFields,
...this.errors, ...newErrors,
}; };
}, },
}, },
methods: { methods: {
handleCancel() { handleCancel() {
this.fields = { const { initialFields = {}, errors = null } = this;
...defaultFields, const formData = initializeFormData({
...this.initialFields, emptyFieldState: defaultFields,
}; initialFields,
errors,
});
this.$set(this, 'fields', formData.fields);
this.$set(this, 'fieldErrors', formData.fieldErrors);
this.$emit('cancel'); this.$emit('cancel');
}, },
handleSave() { handleSave() {
...@@ -206,6 +236,15 @@ export default { ...@@ -206,6 +236,15 @@ export default {
fieldErrorMessage(key) { fieldErrorMessage(key) {
return this.fieldErrors[key]?.join('\n'); return this.fieldErrors[key]?.join('\n');
}, },
onUpdateNameField() {
if (DEFAULT_STAGE_NAMES.includes(this.fields.name.toLowerCase())) {
this.$set(this.fieldErrors, 'name', [
s__('CustomCycleAnalytics|Stage name already exists'),
]);
} else {
this.$set(this.fieldErrors, 'name', []);
}
},
onUpdateStartEventField() { onUpdateStartEventField() {
const initVal = this.initialFields?.endEventIdentifier const initVal = this.initialFields?.endEventIdentifier
? this.initialFields.endEventIdentifier ? this.initialFields.endEventIdentifier
...@@ -255,6 +294,7 @@ export default { ...@@ -255,6 +294,7 @@ export default {
name="custom-stage-name" name="custom-stage-name"
:placeholder="s__('CustomCycleAnalytics|Enter a name for the stage')" :placeholder="s__('CustomCycleAnalytics|Enter a name for the stage')"
required required
@change.native="onUpdateNameField"
/> />
</gl-form-group> </gl-form-group>
<div class="d-flex" :class="{ 'justify-content-between': startEventRequiresLabel }"> <div class="d-flex" :class="{ 'justify-content-between': startEventRequiresLabel }">
...@@ -298,7 +338,6 @@ export default { ...@@ -298,7 +338,6 @@ export default {
:label="s__('CustomCycleAnalytics|Stop event')" :label="s__('CustomCycleAnalytics|Stop event')"
:state="!hasFieldErrors('endEventIdentifier')" :state="!hasFieldErrors('endEventIdentifier')"
:invalid-feedback="fieldErrorMessage('endEventIdentifier')" :invalid-feedback="fieldErrorMessage('endEventIdentifier')"
@change.native="onUpdateEndEventField"
> >
<gl-form-select <gl-form-select
v-model="fields.endEventIdentifier" v-model="fields.endEventIdentifier"
...@@ -306,6 +345,7 @@ export default { ...@@ -306,6 +345,7 @@ export default {
:options="endEventOptions" :options="endEventOptions"
:required="true" :required="true"
:disabled="!hasStartEvent" :disabled="!hasStartEvent"
@change.native="onUpdateEndEventField"
/> />
</gl-form-group> </gl-form-group>
</div> </div>
......
...@@ -30,6 +30,8 @@ export const EMPTY_STAGE_TEXT = { ...@@ -30,6 +30,8 @@ export const EMPTY_STAGE_TEXT = {
), ),
}; };
export const DEFAULT_STAGE_NAMES = [...Object.keys(EMPTY_STAGE_TEXT), 'total'];
export const TASKS_BY_TYPE_SUBJECT_ISSUE = 'Issue'; export const TASKS_BY_TYPE_SUBJECT_ISSUE = 'Issue';
export const TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST = 'MergeRequest'; export const TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST = 'MergeRequest';
......
...@@ -488,11 +488,10 @@ describe 'Group Value Stream Analytics', :js do ...@@ -488,11 +488,10 @@ describe 'Group Value Stream Analytics', :js do
end end
it 'with a default name' do it 'with a default name' do
name = 'issue' fill_in 'custom-stage-name', with: 'issue'
fill_in 'custom-stage-name', with: name
click_button 'Add stage' click_button 'Add stage'
expect(page.find('.flash-alert')).to have_text("'#{name}' stage already exists") expect(page).to have_button('Add stage', disabled: true)
end end
end end
...@@ -616,11 +615,10 @@ describe 'Group Value Stream Analytics', :js do ...@@ -616,11 +615,10 @@ describe 'Group Value Stream Analytics', :js do
end end
it 'with a default name' do it 'with a default name' do
name = 'issue' fill_in name_field, with: 'issue'
fill_in name_field, with: name
page.find(stage_save_button).click page.find(stage_save_button).click
expect(page.find('.flash-alert')).to have_text("'#{name}' stage already exists") expect(page.find(stage_form_class)).to have_text("Stage name already exists")
end end
end end
end end
......
...@@ -7,7 +7,7 @@ exports[`CustomStageForm Editing a custom stage isSavingCustomStage=true display ...@@ -7,7 +7,7 @@ exports[`CustomStageForm Editing a custom stage isSavingCustomStage=true display
`; `;
exports[`CustomStageForm 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__177\\"> "<select name=\\"custom-stage-start-event\\" required=\\"required\\" aria-required=\\"true\\" class=\\"gl-form-select custom-select\\" id=\\"__BVID__257\\">
<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>
<option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option> <option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option>
...@@ -30,7 +30,7 @@ exports[`CustomStageForm Start event with events does not select events with can ...@@ -30,7 +30,7 @@ exports[`CustomStageForm Start event with events does not select events with can
`; `;
exports[`CustomStageForm 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__137\\"> "<select name=\\"custom-stage-start-event\\" required=\\"required\\" aria-required=\\"true\\" class=\\"gl-form-select custom-select\\" id=\\"__BVID__217\\">
<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>
<option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option> <option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option>
......
...@@ -2,7 +2,9 @@ import Vue from 'vue'; ...@@ -2,7 +2,9 @@ import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import createStore from 'ee/analytics/cycle_analytics/store'; import createStore from 'ee/analytics/cycle_analytics/store';
import { createLocalVue, mount } from '@vue/test-utils'; import { createLocalVue, mount } from '@vue/test-utils';
import CustomStageForm from 'ee/analytics/cycle_analytics/components/custom_stage_form.vue'; import CustomStageForm, {
initializeFormData,
} from 'ee/analytics/cycle_analytics/components/custom_stage_form.vue';
import { STAGE_ACTIONS } from 'ee/analytics/cycle_analytics/constants'; import { STAGE_ACTIONS } from 'ee/analytics/cycle_analytics/constants';
import { import {
groupLabels, groupLabels,
...@@ -77,6 +79,15 @@ describe('CustomStageForm', () => { ...@@ -77,6 +79,15 @@ describe('CustomStageForm', () => {
}); });
} }
const findNameField = _wrapper => _wrapper.find({ ref: 'name' });
const findNameFieldInput = _wrapper => _wrapper.find(sel.name);
function setNameField(_wrapper, value = '') {
findNameFieldInput(_wrapper).setValue(value);
findNameFieldInput(_wrapper).trigger('change');
return _wrapper.vm.$nextTick();
}
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({}); wrapper = createComponent({});
}); });
...@@ -104,6 +115,25 @@ describe('CustomStageForm', () => { ...@@ -104,6 +115,25 @@ describe('CustomStageForm', () => {
}); });
}); });
describe('Name', () => {
describe('with a reserved name', () => {
beforeEach(() => {
wrapper = createComponent({});
return setNameField(wrapper, 'issue');
});
it('displays an error', () => {
expect(findNameField(wrapper).text()).toContain('Stage name already exists');
});
it('clears the error when the field changes', () => {
return setNameField(wrapper, 'not an issue').then(() => {
expect(findNameField(wrapper).text()).not.toContain('Stage name already exists');
});
});
});
});
describe('Start event', () => { describe('Start event', () => {
describe('with events', () => { describe('with events', () => {
beforeEach(() => { beforeEach(() => {
...@@ -163,7 +193,6 @@ describe('CustomStageForm', () => { ...@@ -163,7 +193,6 @@ describe('CustomStageForm', () => {
expect(wrapper.vm.fields.startEventLabelId).toEqual(null); expect(wrapper.vm.fields.startEventLabelId).toEqual(null);
wrapper.find(sel.startEvent).setValue(labelStartEvent.identifier); wrapper.find(sel.startEvent).setValue(labelStartEvent.identifier);
// TODO: make func for setting single field
return Vue.nextTick() return Vue.nextTick()
.then(() => { .then(() => {
wrapper wrapper
...@@ -749,4 +778,118 @@ describe('CustomStageForm', () => { ...@@ -749,4 +778,118 @@ describe('CustomStageForm', () => {
}); });
}); });
}); });
describe('initializeFormData', () => {
describe('without a startEventIdentifier', () => {
it('with no errors', () => {
const res = initializeFormData({
initialFields: {},
});
expect(res.fields).toEqual({});
expect(res.fieldErrors).toEqual({
endEventIdentifier: ['Please select a start event first'],
});
});
it('with field errors', () => {
const res = initializeFormData({
initialFields: {},
errors: {
name: ['is reserved'],
},
});
expect(res.fields).toEqual({});
expect(res.fieldErrors).toEqual({
endEventIdentifier: ['Please select a start event first'],
name: ['is reserved'],
});
});
});
describe('with a startEventIdentifier', () => {
it('with no errors', () => {
const res = initializeFormData({
initialFields: {
startEventIdentifier: 'start-event',
},
errors: {},
});
expect(res.fields).toEqual({ startEventIdentifier: 'start-event' });
expect(res.fieldErrors).toEqual({
endEventIdentifier: null,
});
});
it('with field errors', () => {
const res = initializeFormData({
initialFields: {
startEventIdentifier: 'start-event',
},
errors: {
name: ['is reserved'],
},
});
expect(res.fields).toEqual({ startEventIdentifier: 'start-event' });
expect(res.fieldErrors).toEqual({
endEventIdentifier: null,
name: ['is reserved'],
});
});
});
describe('with all fields set', () => {
it('with no errors', () => {
const res = initializeFormData({
initialFields: {
id: 1,
name: 'cool-stage',
startEventIdentifier: 'start-event',
endEventIdentifier: 'end-event',
startEventLabelId: 10,
endEventLabelId: 20,
},
errors: {},
});
expect(res.fields).toEqual({
id: 1,
name: 'cool-stage',
startEventIdentifier: 'start-event',
endEventIdentifier: 'end-event',
startEventLabelId: 10,
endEventLabelId: 20,
});
expect(res.fieldErrors).toEqual({
endEventIdentifier: null,
});
});
it('with field errors', () => {
const res = initializeFormData({
initialFields: {
id: 1,
name: 'cool-stage',
startEventIdentifier: 'start-event',
endEventIdentifier: 'end-event',
startEventLabelId: 10,
endEventLabelId: 20,
},
errors: {
name: ['is reserved'],
},
});
expect(res.fields).toEqual({
id: 1,
name: 'cool-stage',
startEventIdentifier: 'start-event',
endEventIdentifier: 'end-event',
startEventLabelId: 10,
endEventLabelId: 20,
});
expect(res.fieldErrors).toEqual({
endEventIdentifier: null,
name: ['is reserved'],
});
});
});
});
}); });
...@@ -5848,6 +5848,9 @@ msgstr "" ...@@ -5848,6 +5848,9 @@ msgstr ""
msgid "CustomCycleAnalytics|Select stop event" msgid "CustomCycleAnalytics|Select stop event"
msgstr "" msgstr ""
msgid "CustomCycleAnalytics|Stage name already exists"
msgstr ""
msgid "CustomCycleAnalytics|Start event" msgid "CustomCycleAnalytics|Start event"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment