Commit 08593361 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo Committed by Kushal Pandya

FE - Persist custom cycle analytics stages

Adds vuex actions to create a custom cycle
cycle analytics stage using the new
/-/analytics/cycle_analytics/stages endpoint
parent b70403cf
......@@ -48,6 +48,7 @@ export default {
'isLoadingStage',
'isEmptyStage',
'isAddingCustomStage',
'isSavingCustomStage',
'selectedGroup',
'selectedProjectIds',
'selectedStageId',
......@@ -87,6 +88,7 @@ export default {
'fetchCustomStageFormData',
'fetchCycleAnalyticsData',
'fetchStageData',
'fetchGroupStagesAndEvents',
'setCycleAnalyticsDataEndpoint',
'setStageDataEndpoint',
'setSelectedGroup',
......@@ -97,6 +99,7 @@ export default {
'hideCustomStageForm',
'showCustomStageForm',
'setDateRange',
'createCustomStage',
]),
onGroupSelect(group) {
this.setCycleAnalyticsDataEndpoint(group.full_path);
......@@ -122,6 +125,9 @@ export default {
const startDate = getDateInPast(endDate, DEFAULT_DAYS_IN_PAST);
this.setDateRange({ skipFetch: true, startDate, endDate });
},
onCreateCustomStage(data) {
this.createCustomStage(data);
},
},
groupsQueryParams: {
min_access_level: featureAccessLevel.EVERYONE,
......@@ -209,6 +215,7 @@ export default {
:is-loading="isLoadingStage"
:is-empty-stage="isEmptyStage"
:is-adding-custom-stage="isAddingCustomStage"
:is-saving-custom-stage="isSavingCustomStage"
:current-stage-events="currentStageEvents"
:custom-stage-form-events="customStageFormEvents"
:labels="labels"
......@@ -217,6 +224,7 @@ export default {
:can-edit-stages="hasCustomizableCycleAnalytics"
@selectStage="onStageSelect"
@showAddStageForm="onShowAddStageForm"
@submit="onCreateCustomStage"
/>
</div>
</div>
......
<script>
import { isEqual } from 'underscore';
import { GlButton, GlFormGroup, GlFormInput, GlFormSelect } from '@gitlab/ui';
import { GlButton, GlFormGroup, GlFormInput, GlFormSelect, GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
import LabelsSelector from './labels_selector.vue';
import {
......@@ -26,6 +26,7 @@ export default {
GlFormGroup,
GlFormInput,
GlFormSelect,
GlLoadingIcon,
LabelsSelector,
},
props: {
......@@ -44,6 +45,11 @@ export default {
...initFields,
}),
},
isSavingCustomStage: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
......@@ -116,7 +122,14 @@ export default {
this.$emit('cancel');
},
handleSave() {
this.$emit('submit', this.fields);
const { startEvent, startEventLabel, stopEvent, stopEventLabel, name } = this.fields;
this.$emit('submit', {
name,
start_event_identifier: startEvent,
start_event_label_id: startEventLabel,
end_event_identifier: stopEvent,
end_event_label_id: stopEventLabel,
});
},
handleSelectLabel(key, labelId = null) {
this.fields[key] = labelId;
......@@ -128,7 +141,7 @@ export default {
};
</script>
<template>
<form class="add-stage-form m-4">
<form class="custom-stage-form m-4 mt-0">
<div class="mb-1">
<h4>{{ s__('CustomCycleAnalytics|New stage') }}</h4>
</div>
......@@ -137,7 +150,7 @@ export default {
v-model="fields.name"
class="form-control"
type="text"
name="add-stage-name"
name="custom-stage-name"
:placeholder="s__('CustomCycleAnalytics|Enter a name for the stage')"
required
/>
......@@ -147,7 +160,7 @@ export default {
<gl-form-group :label="s__('CustomCycleAnalytics|Start event')">
<gl-form-select
v-model="fields.startEvent"
name="add-stage-start-event"
name="custom-stage-start-event"
:required="true"
:options="startEventOptions"
/>
......@@ -158,7 +171,7 @@ export default {
<labels-selector
:labels="labels"
:selected-label-id="fields.startEventLabel"
name="add-stage-start-event-label"
name="custom-stage-start-event-label"
@selectLabel="labelId => handleSelectLabel('startEventLabel', labelId)"
@clearLabel="handleClearLabel('startEventLabel')"
/>
......@@ -177,7 +190,7 @@ export default {
>
<gl-form-select
v-model="fields.stopEvent"
name="add-stage-stop-event"
name="custom-stage-stop-event"
:options="stopEventOptions"
:required="true"
:disabled="!hasStartEvent"
......@@ -189,7 +202,7 @@ export default {
<labels-selector
:labels="labels"
:selected-label-id="fields.stopEventLabel"
name="add-stage-stop-event-label"
name="custom-stage-stop-event-label"
@selectLabel="labelId => handleSelectLabel('stopEventLabel', labelId)"
@clearLabel="handleClearLabel('stopEventLabel')"
/>
......@@ -197,10 +210,10 @@ export default {
</div>
</div>
<div class="add-stage-form-actions">
<div class="custom-stage-form-actions">
<button
:disabled="!isDirty"
class="btn btn-cancel js-add-stage-cancel"
class="btn btn-cancel js-custom-stage-form-cancel"
type="button"
@click="handleCancel"
>
......@@ -209,9 +222,10 @@ export default {
<button
:disabled="!isComplete || !isDirty"
type="button"
class="js-add-stage btn btn-success"
class="js-custom-stage-form-submit btn btn-success"
@click="handleSave"
>
<gl-loading-icon v-if="isSavingCustomStage" size="sm" inline />
{{ s__('CustomCycleAnalytics|Add stage') }}
</button>
</div>
......
......@@ -44,6 +44,10 @@ export default {
type: Boolean,
required: true,
},
isSavingCustomStage: {
type: Boolean,
required: true,
},
currentStageEvents: {
type: Array,
required: true,
......@@ -95,23 +99,17 @@ export default {
title: this.stageName,
description: __('The collection of events added to the data gathered for that stage.'),
classes: 'event-header pl-3',
displayHeader: !this.isAddingCustomStage,
},
{
title: __('Total Time'),
description: __('The time taken by each data entry gathered by that stage.'),
classes: 'total-time-header pr-5 text-right',
displayHeader: !this.isAddingCustomStage,
},
];
},
},
methods: {
selectStage(stage) {
this.$emit('selectStage', stage);
},
showAddStageForm() {
this.$emit('showAddStageForm');
},
},
};
</script>
<template>
......@@ -121,7 +119,8 @@ export default {
<nav class="col-headers">
<ul>
<stage-table-header
v-for="({ title, description, classes }, i) in stageHeaders"
v-for="({ title, description, classes, displayHeader = true }, i) in stageHeaders"
v-show="displayHeader"
:key="`stage-header-${i}`"
:header-classes="classes"
:title="title"
......@@ -140,12 +139,12 @@ export default {
:value="stage.value"
:is-active="!isAddingCustomStage && stage.id === currentStage.id"
:is-default-stage="!stage.custom"
@select="selectStage(stage)"
@select="$emit('selectStage', stage)"
/>
<add-stage-button
v-if="canEditStages"
:active="isAddingCustomStage"
@showform="showAddStageForm"
@showform="$emit('showAddStageForm')"
/>
</ul>
</nav>
......@@ -155,6 +154,8 @@ export default {
v-else-if="isAddingCustomStage"
:events="customStageFormEvents"
:labels="labels"
:is-saving-custom-stage="isSavingCustomStage"
@submit="$emit('submit', $event)"
/>
<template v-else>
<stage-event-list
......
......@@ -157,3 +157,38 @@ export const fetchGroupStagesAndEvents = ({ state, dispatch, getters }) => {
.then(({ data }) => dispatch('receiveGroupStagesAndEventsSuccess', data))
.catch(error => dispatch('receiveGroupStagesAndEventsError', error));
};
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');
return dispatch('fetchGroupStagesAndEvents').then(() => dispatch('fetchSummaryData'));
};
export const receiveCreateCustomStageError = ({ commit }, { error, data }) => {
commit(types.RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE);
const { name } = data;
const { status } = error;
const message =
status !== httpStatus.UNPROCESSABLE_ENTITY
? __(`'${name}' stage already exists'`)
: __('There was a problem saving your custom stage, please try again');
createFlash(message);
};
export const createCustomStage = ({ dispatch, state }, data) => {
const {
selectedGroup: { fullPath },
} = state;
const endpoint = `/-/analytics/cycle_analytics/stages?group_id=${fullPath}`;
dispatch('requestCreateCustomStage');
axios
.post(endpoint, data)
.then(response => dispatch('receiveCreateCustomStageSuccess', response))
.catch(error => dispatch('receiveCreateCustomStageError', { error, data }));
};
......@@ -29,3 +29,6 @@ export const RECEIVE_SUMMARY_DATA_ERROR = 'RECEIVE_SUMMARY_DATA_ERROR';
export const REQUEST_GROUP_STAGES_AND_EVENTS = 'REQUEST_GROUP_STAGES_AND_EVENTS';
export const RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS = 'RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS';
export const RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR = 'RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR';
export const REQUEST_CREATE_CUSTOM_STAGE = 'REQUEST_CREATE_CUSTOM_STAGE';
export const RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE = 'RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE';
......@@ -120,4 +120,10 @@ export default {
state.selectedStageId = id;
}
},
[types.REQUEST_CREATE_CUSTOM_STAGE](state) {
state.isSavingCustomStage = true;
},
[types.RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE](state) {
state.isSavingCustomStage = false;
},
};
......@@ -16,6 +16,7 @@ export default () => ({
errorCode: null,
isAddingCustomStage: false,
isSavingCustomStage: false,
selectedGroup: null,
selectedProjectIds: [],
......
......@@ -61,7 +61,7 @@ describe 'Group Cycle Analytics', :js do
dropdown.click
dropdown.find('a').click
wait_for_requests
wait_for_stages_to_load
end
def select_project
......@@ -218,11 +218,7 @@ describe 'Group Cycle Analytics', :js do
context 'enabled' do
before do
dropdown = page.find('.dropdown-groups')
dropdown.click
dropdown.find('a').click
wait_for_stages_to_load
select_group
end
context 'Add a stage button' do
......@@ -247,6 +243,77 @@ describe 'Group Cycle Analytics', :js do
expect(page).to have_text('New stage')
end
end
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
page.find(show_form_button_class).click
wait_for_requests
end
context 'with empty fields' do
it 'submit button is disabled by default' do
expect(page).to have_button('Add stage', disabled: true)
end
end
context 'with all required fields set' do
custom_stage_name = "cool beans"
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 'submit button is enabled' do
expect(page).to have_button('Add stage', disabled: false)
end
it 'submit button is disabled if the start event changes' do
select_dropdown_option 'custom-stage-start-event', 'option', 2
expect(page).to have_button('Add stage', disabled: true)
end
it '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)
end
it 'a confirmation message is displayed' do
name = 'cool beans number 2'
fill_in 'custom-stage-name', with: name
click_button 'Add stage'
expect(page.find('.flash-notice')).to have_text("Your custom 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
end
end
end
context 'not enabled' do
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CustomStageForm does not have a loading icon 1`] = `
"<button type=\\"button\\" class=\\"js-custom-stage-form-submit btn btn-success\\"><!---->
Add stage
</button>"
`;
exports[`CustomStageForm isSavingCustomStage=true displays a loading icon 1`] = `
"<button disabled=\\"disabled\\" type=\\"button\\" class=\\"js-custom-stage-form-submit 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>"
`;
......@@ -33,13 +33,13 @@ describe('CustomStageForm', () => {
let wrapper = null;
const sel = {
name: '[name="add-stage-name"]',
startEvent: '[name="add-stage-start-event"]',
startEventLabel: '[name="add-stage-start-event-label"]',
stopEvent: '[name="add-stage-stop-event"]',
stopEventLabel: '[name="add-stage-stop-event-label"]',
submit: '.js-add-stage',
cancel: '.js-add-stage-cancel',
name: '[name="custom-stage-name"]',
startEvent: '[name="custom-stage-start-event"]',
startEventLabel: '[name="custom-stage-start-event-label"]',
stopEvent: '[name="custom-stage-stop-event"]',
stopEventLabel: '[name="custom-stage-stop-event-label"]',
submit: '.js-custom-stage-form-submit',
cancel: '.js-custom-stage-form-cancel',
invalidFeedback: '.invalid-feedback',
};
......@@ -405,10 +405,10 @@ describe('CustomStageForm', () => {
const res = [
{
name: 'Cool stage',
startEvent: startEv.identifier,
startEventLabel: null,
stopEvent: selectedStopEvent.attributes('value'),
stopEventLabel: null,
start_event_identifier: startEv.identifier,
start_event_label_id: null,
end_event_identifier: selectedStopEvent.attributes('value'),
end_event_label_id: null,
},
];
......@@ -597,11 +597,38 @@ describe('CustomStageForm', () => {
Vue.nextTick(() => {
const submitted = wrapper.emitted().submit[0];
expect(submitted).not.toEqual([initData]);
expect(submitted).toEqual([{ ...initData, name: 'Cool updated form' }]);
expect(submitted).toEqual([
{
start_event_identifier: labelStartEvent.identifier,
start_event_label_id: groupLabels[0].id,
end_event_identifier: labelStopEvent.identifier,
end_event_label_id: groupLabels[1].id,
name: 'Cool updated form',
},
]);
done();
});
});
});
});
});
it('does not have a loading icon', () => {
expect(wrapper.find(sel.submit).html()).toMatchSnapshot();
});
describe('isSavingCustomStage=true', () => {
beforeEach(() => {
wrapper = createComponent(
{
isSavingCustomStage: true,
},
false,
);
});
it('displays a loading icon', () => {
expect(wrapper.find(sel.submit).html()).toMatchSnapshot();
});
});
});
import Vue from 'vue';
import { shallowMount, mount } from '@vue/test-utils';
import StageTable from 'ee/analytics/cycle_analytics/components/stage_table.vue';
import { issueEvents, issueStage, allowedStages, groupLabels } from '../mock_data';
import {
issueEvents,
issueStage,
allowedStages,
groupLabels,
customStageEvents,
} from '../mock_data';
let wrapper = null;
const $sel = {
......@@ -30,10 +36,11 @@ function createComponent(props = {}, shallow = false) {
isLoading: false,
isEmptyStage: false,
isAddingCustomStage: false,
isSavingCustomStage: false,
noDataSvgPath,
noAccessSvgPath,
canEditStages: false,
customStageFormEvents: [],
customStageFormEvents: customStageEvents,
...props,
},
stubs: {
......
......@@ -45,6 +45,8 @@ describe('Cycle analytics mutations', () => {
${types.REQUEST_GROUP_STAGES_AND_EVENTS} | ${'stages'} | ${[]}
${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}
`('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => {
mutations[mutation](state);
......
......@@ -17260,6 +17260,9 @@ msgstr ""
msgid "There was a problem communicating with your device."
msgstr ""
msgid "There was a problem saving your custom stage, please try again"
msgstr ""
msgid "There was a problem sending the confirmation email"
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