Commit a6e0d598 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Added custom stage form

Minor cleanup form ui

Load form on trigger

Added Additional translations

Minor rename form and boolean flag

Fixed field labels and descriptions

Scaffold form tests

Basic tests for default states

Added test data from new endpoint

Filter start events from events array

Added tests for dropdown behaviour

Start events will have can_be_start_event=true,
stop events can only be set after a start event,
the stop events displayed depends on the start
event selected.

Added test for custom_stage_form submission

Minor refactor test helpers
parent 26d75904
<script>
import { s__ } from '~/locale';
import { GlButton, GlFormGroup, GlFormInput, GlFormSelect } from '@gitlab/ui';
const isStartEvent = ev => ev && ev.can_be_start_event;
const eventToOption = ({ name: text = '', identifier: value = null }) => ({
text,
value,
});
const getAllowedStopEvents = (events = [], targetIdentifier = null) => {
if (!targetIdentifier || !events.length) return [];
const st = events.find(({ identifier }) => identifier === targetIdentifier);
return st.allowed_end_events;
};
const eventsByIdentifier = (events = [], targetIdentifier = []) => {
if (!targetIdentifier.length || !events.length) return [];
return events.filter(({ identifier }) => targetIdentifier.indexOf(identifier) > -1);
};
export default {
components: {
GlButton,
GlFormGroup,
GlFormInput,
GlFormSelect,
},
props: {
events: {
type: Array,
required: true,
},
// name: {
// type: String,
// default: null,
// },
// objectType: {
// type: String,
// default: null,
// },
// startEvent: {
// type: String,
// default: null,
// },
// stopEvent: {
// type: String,
// default: null,
// },
},
data() {
return {
fields: {
// objectType: null,
name: '',
startEvent: '',
startEventLabel: '',
stopEvent: '',
stopEventLabel: '',
},
};
},
computed: {
stopEventOptions() {
const stopEvents = getAllowedStopEvents(this.events, this.fields.startEvent);
return [
{ value: null, text: s__('CustomCycleAnalytics|Select stop event') },
...eventsByIdentifier(this.events, stopEvents).map(eventToOption),
];
},
startEventOptions() {
return [
{ value: null, text: s__('CustomCycleAnalytics|Select start event') },
...this.events.filter(isStartEvent).map(eventToOption),
];
},
hasStartEvent() {
return this.fields.startEvent;
},
// startEventRequiresLabel() {},
// stopEventRequiresLabel() {},
isComplete() {
// TODO: need to factor in label field
const requiredFields = [this.fields.startEvent, this.fields.stopEvent, this.fields.name];
return requiredFields.every(fieldValue => fieldValue && fieldValue.length > 0);
},
},
methods: {
handleSave() {
this.$emit('submit', this.fields);
},
},
};
</script>
<template>
<form class="add-stage-form m-4">
<div class="mb-1">
<h4>{{ s__('CustomCycleAnalytics|New stage') }}</h4>
</div>
<gl-form-group :label="s__('CustomCycleAnalytics|Name')">
<gl-form-input
v-model="fields.name"
class="form-control"
type="text"
value=""
name="add-stage-name"
:placeholder="s__('CustomCycleAnalytics|Enter a name for the stage')"
required
/>
</gl-form-group>
<gl-form-group :label="s__('CustomCycleAnalytics|Start event')">
<gl-form-select
v-model="fields.startEvent"
name="add-stage-start-event"
:required="true"
:options="startEventOptions"
/>
</gl-form-group>
<gl-form-group
:label="s__('CustomCycleAnalytics|Stop event')"
:description="s__('CustomCycleAnalytics|Please select a start event first')"
>
<gl-form-select
v-model="fields.stopEvent"
name="add-stage-stop-event"
:options="stopEventOptions"
:required="true"
:disabled="!hasStartEvent"
/>
</gl-form-group>
<div class="add-stage-form-actions">
<!--
TODO: what does the cancel button do?
- Just hide the form?
- clear entered data?
-->
<button class="btn btn-cancel add-stage-cancel" type="button" @click="cancelHandler()">
{{ __('Cancel') }}
</button>
<button
:disabled="!isComplete"
type="button"
class="js-add-stage btn btn-success"
@click="handleSave"
>
{{ s__('CustomCycleAnalytics|Add stage') }}
</button>
</div>
</form>
</template>
......@@ -8,6 +8,7 @@ import Flash from '../flash';
import { __ } from '~/locale';
import Translate from '../vue_shared/translate';
import banner from './components/banner.vue';
// TODO: should the be capitalized?
import stageCodeComponent from './components/stage_code_component.vue';
import stageComponent from './components/stage_component.vue';
import stageReviewComponent from './components/stage_review_component.vue';
......
......@@ -4535,9 +4535,42 @@ msgstr ""
msgid "CustomCycleAnalytics|Add a stage"
msgstr ""
msgid "CustomCycleAnalytics|Add stage"
msgstr ""
msgid "CustomCycleAnalytics|Choose which object types will trigger this stage"
msgstr ""
msgid "CustomCycleAnalytics|Enter a name for the stage"
msgstr ""
msgid "CustomCycleAnalytics|Name"
msgstr ""
msgid "CustomCycleAnalytics|New stage"
msgstr ""
msgid "CustomCycleAnalytics|Object type"
msgstr ""
msgid "CustomCycleAnalytics|Please select a start event first"
msgstr ""
msgid "CustomCycleAnalytics|Select one or more objects"
msgstr ""
msgid "CustomCycleAnalytics|Select start event"
msgstr ""
msgid "CustomCycleAnalytics|Select stop event"
msgstr ""
msgid "CustomCycleAnalytics|Start event"
msgstr ""
msgid "CustomCycleAnalytics|Stop event"
msgstr ""
msgid "Customize colors"
msgstr ""
......
import Vue from 'vue';
import { mount, shallowMount } from '@vue/test-utils';
import CustomStageForm from '~/cycle_analytics/components/custom_stage_form.vue';
import mockData from './cycle_analytics.json';
const {
apiResponse: { events },
rawEvents,
} = mockData;
const startEvents = events.filter(ev => ev.can_be_start_event);
const stopEvents = events.filter(ev => !ev.can_be_start_event);
describe('CustomStageForm', () => {
function createComponent(props, shallow = true) {
const func = shallow ? shallowMount : mount;
return func(CustomStageForm, {
propsData: {
events,
...props,
},
sync: false, // fixes '$listeners is readonly' errors
});
}
let wrapper = null;
const sel = {
name: '[name="add-stage-name"]',
startEvent: '[name="add-stage-start-event"]',
stopEvent: '[name="add-stage-stop-event"]',
submit: '.js-add-stage',
};
function selectDropdownOption(_wrapper, dropdown, index) {
_wrapper
.find(dropdown)
.findAll('option')
.at(index)
.setSelected();
}
describe('Empty form', () => {
beforeEach(() => {
wrapper = createComponent({}, false);
});
describe.each([
['Name', sel.name, true],
['Start event', sel.startEvent, true],
['Stop event', sel.stopEvent, false],
['Submit', sel.submit, false],
])('by default', (field, $sel, enabledState) => {
const state = enabledState ? 'enabled' : 'disabled';
it(`field '${field}' is ${state}`, () => {
const el = wrapper.find($sel);
expect(el.exists()).toBe(true);
if (!enabledState) {
expect(el.attributes('disabled')).toBe('disabled');
} else {
expect(el.attributes('disabled')).toBeUndefined();
}
});
});
describe('Start event', () => {
describe('with events', () => {
beforeEach(() => {
wrapper = createComponent({}, false);
});
it('selects events with can_be_start_event=true for the start events dropdown', () => {
const select = wrapper.find(sel.startEvent);
startEvents.forEach(ev => {
expect(select.html()).toHaveHtml(
`<option value="${ev.identifier}">${ev.name}</option>`,
);
});
stopEvents.forEach(ev => {
expect(select.html()).not.toHaveHtml(
`<option value="${ev.identifier}">${ev.name}</option>`,
);
});
});
it('will exclude stop events for the dropdown', () => {
const select = wrapper.find(sel.startEvent);
stopEvents.forEach(ev => {
expect(select.html()).not.toHaveHtml(
`<option value="${ev.identifier}">${ev.name}</option>`,
);
});
});
});
describe.skip('start event label', () => {
it('is hidden by default', () => {});
it('will display the start event label field if a label event is selected', () => {});
});
});
describe('Stop event', () => {
beforeEach(() => {
wrapper = createComponent(
{
events: rawEvents,
},
false,
);
});
it('is enabled when a start event is selected', () => {
const el = wrapper.find(sel.stopEvent);
expect(el.attributes('disabled')).toBe('disabled');
const opts = wrapper.find(sel.startEvent).findAll('option');
opts.at(1).setSelected();
Vue.nextTick(() => expect(el.attributes('disabled')).toBeUndefined());
});
it('will update the list of stop events when a start event is changed', () => {
let stopOptions = wrapper.find(sel.stopEvent).findAll('option');
expect(stopOptions.length).toBe(1);
selectDropdownOption(wrapper, sel.startEvent, 1);
Vue.nextTick(() => {
stopOptions = wrapper.find(sel.stopEvent).findAll('option');
expect(stopOptions.length).toBe(3);
});
});
it('will only display valid stop events allowed for the selected start event', () => {
let stopOptions = wrapper.find(sel.stopEvent).findAll('option');
expect(stopOptions.at(0).html()).toEqual('<option value="">Select stop event</option>');
selectDropdownOption(wrapper, sel.startEvent, 1);
Vue.nextTick(() => {
stopOptions = wrapper.find(sel.stopEvent).findAll('option');
[
{ name: 'Select stop event', identifier: '' },
{ name: 'Issue closed', identifier: 'issue_closed' },
{ name: 'Issue merged', identifier: 'issue_merged' },
].forEach(({ name, identifier }, i) => {
expect(stopOptions.at(i).html()).toEqual(
`<option value="${identifier}">${name}</option>`,
);
});
[
{ name: 'Issue created', identifier: 'issue_created' },
{ name: 'Merge request closed', identifier: 'merge_request_closed' },
].forEach(({ name, identifier }) => {
expect(wrapper.find(sel.stopEvent).html()).not.toHaveHtml(
`<option value="${identifier}">${name}</option>`,
);
});
});
});
describe.skip('Stop event label', () => {
it('is hidden by default', () => {});
it('will display the stop event label field if a label event is selected', () => {});
});
});
describe('Add stage button', () => {
beforeEach(() => {
wrapper = createComponent({}, false);
selectDropdownOption(wrapper, sel.startEvent, 1);
Vue.nextTick(() => {
selectDropdownOption(wrapper, sel.stopEvent, 1);
});
});
it('is enabled when all required fields are filled', () => {
const btn = wrapper.find(sel.submit);
expect(btn.attributes('disabled')).toBe('disabled');
wrapper.find(sel.name).setValue('Cool stage');
Vue.nextTick(() => {
expect(btn.attributes('disabled')).toBeUndefined();
});
});
describe('with all fields set', () => {
beforeEach(() => {
wrapper = createComponent({}, false);
selectDropdownOption(wrapper, sel.startEvent, 1);
Vue.nextTick(() => {
selectDropdownOption(wrapper, sel.stopEvent, 1);
wrapper.find(sel.name).setValue('Cool stage');
});
});
it('emits a `submit` event when clicked', () => {
const btn = wrapper.find(sel.submit);
expect(wrapper.emitted().submit).toBeUndefined();
btn.trigger('click');
expect(wrapper.emitted().submit).toBeTruthy();
expect(wrapper.emitted().submit.length).toBe(1);
});
it('`submit` event receives the latest data', () => {
const btn = wrapper.find(sel.submit);
expect(wrapper.emitted().submit).toBeUndefined();
const res = [
{
name: 'Cool stage',
startEvent: 'issue_created',
startEventLabel: '',
stopEvent: 'issue_stage_end',
stopEventLabel: '',
},
];
btn.trigger('click');
expect(wrapper.emitted().submit[0]).toEqual(res);
});
});
});
});
describe.skip('Prepopulated form', () => {
describe('Add stage button', () => {
it('is disabled by default', () => {});
it('is enabled when a field is changed and fields are valid', () => {});
it('emits a `submit` event when clicked', () => {});
});
});
});
{
"apiResponse": {
"events": [
{
"name": "Issue created",
"identifier": "issue_created",
"type": "simple",
"can_be_start_event": true,
"allowed_end_events": ["issue_stage_end"]
},
{
"name": "Issue first mentioned in a commit",
"identifier": "issue_first_mentioned_in_commit",
"type": "simple",
"can_be_start_event": false,
"allowed_end_events": []
},
{
"name": "Merge request created",
"identifier": "merge_request_created",
"type": "simple",
"can_be_start_event": true,
"allowed_end_events": ["merge_request_merged"]
},
{
"name": "Merge request first deployed to production",
"identifier": "merge_request_first_deployed_to_production",
"type": "simple",
"can_be_start_event": false,
"allowed_end_events": []
},
{
"name": "Merge request last build finish time",
"identifier": "merge_request_last_build_finished",
"type": "simple",
"can_be_start_event": false,
"allowed_end_events": []
},
{
"name": "Merge request last build start time",
"identifier": "merge_request_last_build_started",
"type": "simple",
"can_be_start_event": true,
"allowed_end_events": ["merge_request_last_build_finished"]
},
{
"name": "Merge request merged",
"identifier": "merge_request_merged",
"type": "simple",
"can_be_start_event": true,
"allowed_end_events": ["merge_request_first_deployed_to_production"]
},
{
"name": "Issue first mentioned in a commit",
"identifier": "code_stage_start",
"type": "simple",
"can_be_start_event": true,
"allowed_end_events": ["merge_request_created"]
},
{
"name": "Issue first associated with a milestone or issue first added to a board",
"identifier": "issue_stage_end",
"type": "simple",
"can_be_start_event": false,
"allowed_end_events": []
},
{
"name": "Issue first associated with a milestone or issue first added to a board",
"identifier": "plan_stage_start",
"type": "simple",
"can_be_start_event": true,
"allowed_end_events": ["issue_first_mentioned_in_commit"]
}
],
"stages": [
{
"name": "issue",
"legend": "Related Issues",
"description": "Time before an issue gets scheduled",
"id": "issue",
"position": 1,
"hidden": false,
"custom": false,
"start_event_identifier": "issue_created",
"end_event_identifier": "issue_stage_end"
},
{
"name": "plan",
"legend": "Related Issues",
"description": "Time before an issue starts implementation",
"id": "plan",
"position": 2,
"hidden": false,
"custom": false,
"start_event_identifier": "plan_stage_start",
"end_event_identifier": "issue_first_mentioned_in_commit"
},
{
"name": "code",
"legend": "Related Merged Requests",
"description": "Time until first merge request",
"id": "code",
"position": 3,
"hidden": false,
"custom": false,
"start_event_identifier": "code_stage_start",
"end_event_identifier": "merge_request_created"
},
{
"name": "test",
"legend": "Related Merged Requests",
"description": "Total test time for all commits/merges",
"id": "test",
"position": 4,
"hidden": false,
"custom": false,
"start_event_identifier": "merge_request_last_build_started",
"end_event_identifier": "merge_request_last_build_finished"
},
{
"name": "review",
"legend": "Related Merged Requests",
"description": "Time between merge request creation and merge/close",
"id": "review",
"position": 5,
"hidden": false,
"custom": false,
"start_event_identifier": "merge_request_created",
"end_event_identifier": "merge_request_merged"
},
{
"name": "staging",
"legend": "Related Merged Requests",
"description": "From merge request merge until deploy to production",
"id": "staging",
"position": 6,
"hidden": false,
"custom": false,
"start_event_identifier": "merge_request_merged",
"end_event_identifier": "merge_request_first_deployed_to_production"
},
{
"name": "production",
"legend": "Related Merged Requests",
"description": "From issue creation until deploy to production",
"id": "production",
"position": 7,
"hidden": false,
"custom": false,
"start_event_identifier": "merge_request_merged",
"end_event_identifier": "merge_request_first_deployed_to_production"
}
],
"summary": [
{
"value": 2,
"title": "New Issues"
},
{
"value": 0,
"title": "Commits"
},
{
"value": 0,
"title": "Deploys"
}
],
"permissions": {
"issue": true,
"plan": true,
"code": true,
"test": true,
"review": true,
"staging": true,
"production": true
}
},
"rawEvents": [
{
"name": "Issue created",
"identifier": "issue_created",
"type": "simple",
"can_be_start_event": true,
"allowed_end_events": ["issue_closed", "issue_merged"]
},
{
"name": "Merge request closed",
"identifier": "merge_request_closed",
"type": "simple",
"can_be_start_event": false,
"allowed_end_events": []
},
{
"name": "Issue closed",
"identifier": "issue_closed",
"type": "simple",
"can_be_start_event": false,
"allowed_end_events": []
},
{
"name": "Issue merged",
"identifier": "issue_merged",
"type": "simple",
"can_be_start_event": false,
"allowed_end_events": []
}
]
}
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