Commit 5cf77d7f authored by Illya Klymov's avatar Illya Klymov

Merge branch '214893-update-custom-stages-form-specs' into 'master'

Clean up custom stage form specs

Closes #214893

See merge request gitlab-org/gitlab!30830
parents 7b4a0066 817f6d4d
......@@ -70,14 +70,9 @@ export default {
'endDate',
'medians',
]),
...mapState('customStages', [
'isSavingCustomStage',
'isCreatingCustomStage',
'isEditingCustomStage',
'formEvents',
'formErrors',
'formInitialData',
]),
// NOTE: formEvents are fetched in the same request as the list of stages (fetchGroupStagesAndEvents)
// so i think its ok to bind formEvents here even though its only used as a prop to the custom-stage-form
...mapState('customStages', ['isCreatingCustomStage', 'formEvents']),
...mapGetters([
'hasNoAccessError',
'currentGroupPath',
......@@ -106,9 +101,6 @@ export default {
isLoadingTypeOfWork() {
return this.isLoadingTasksByTypeChartTopLabels || this.isLoadingTasksByTypeChart;
},
isUpdatingCustomStage() {
return this.isEditingCustomStage && this.isSavingCustomStage;
},
hasDateRangeSet() {
return this.startDate && this.endDate;
},
......@@ -169,6 +161,7 @@ export default {
this.showCreateForm();
},
onShowEditStageForm(initData = {}) {
this.setSelectedStage(initData);
this.showEditForm(initData);
},
onCreateCustomStage(data) {
......@@ -299,7 +292,6 @@ export default {
:stages="activeStages"
:medians="medians"
:is-creating-custom-stage="isCreatingCustomStage"
:custom-stage-form-active="customStageFormActive"
:can-edit-stages="true"
:custom-ordering="enableCustomOrdering"
@reorderStage="onStageReorder"
......@@ -311,14 +303,8 @@ export default {
/>
</template>
<template v-if="customStageFormActive" #content>
<gl-loading-icon v-if="isUpdatingCustomStage" class="mt-4" size="md" />
<custom-stage-form
v-else
:events="formEvents"
:is-saving-custom-stage="isSavingCustomStage"
:initial-fields="formInitialData"
:is-editing-custom-stage="isEditingCustomStage"
:errors="formErrors"
@createStage="onCreateCustomStage"
@updateStage="onUpdateCustomStage"
@clearErrors="$emit('clearFormErrors')"
......
<script>
import { mapGetters } from 'vuex';
import { mapGetters, mapState } from 'vuex';
import { isEqual } from 'lodash';
import {
GlFormGroup,
......@@ -33,23 +33,37 @@ const defaultFields = {
endEventLabelId: null,
};
export const initializeFormData = ({ emptyFieldState, initialFields, errors }) => {
const defaultErrors = initialFields?.endEventIdentifier
? { ...emptyFieldState, endEventIdentifier: null }
const defaultErrors = {
id: [],
name: [],
startEventIdentifier: [],
startEventLabelId: [],
endEventIdentifier: [],
endEventLabelId: [],
};
const ERRORS = {
START_EVENT_REQUIRED: s__('CustomCycleAnalytics|Please select a start event first'),
STAGE_NAME_EXISTS: s__('CustomCycleAnalytics|Stage name already exists'),
INVALID_EVENT_PAIRS: s__(
'CustomCycleAnalytics|Start event changed, please select a valid stop event',
),
};
export const initializeFormData = ({ emptyFieldState = defaultFields, fields, errors }) => {
const initErrors = fields?.endEventIdentifier
? defaultErrors
: {
...emptyFieldState,
endEventIdentifier:
initialFields && !initialFields.startEventIdentifier
? [s__('CustomCycleAnalytics|Please select a start event first')]
: null,
...defaultErrors,
endEventIdentifier: !fields?.startEventIdentifier ? [ERRORS.START_EVENT_REQUIRED] : [],
};
return {
fields: {
...emptyFieldState,
...initialFields,
...fields,
},
fieldErrors: {
...defaultErrors,
errors: {
...initErrors,
...errors,
},
};
......@@ -72,42 +86,23 @@ export default {
type: Array,
required: true,
},
initialFields: {
type: Object,
required: false,
default: () => {},
},
isSavingCustomStage: {
type: Boolean,
required: false,
default: false,
},
isEditingCustomStage: {
type: Boolean,
required: false,
default: false,
},
errors: {
type: Object,
required: false,
default: null,
},
},
data() {
const { initialFields = {}, errors = null } = this;
const { fields, fieldErrors } = initializeFormData({
emptyFieldState: defaultFields,
initialFields,
errors,
});
return {
labelEvents: getLabelEventsIdentifiers(this.events),
fields,
fieldErrors,
fields: {},
errors: [],
};
},
computed: {
...mapGetters(['hiddenStages']),
...mapState('customStages', [
'isLoading',
'isSavingCustomStage',
'isEditingCustomStage',
'formInitialData',
'formErrors',
]),
startEventOptions() {
return [
{ value: null, text: s__('CustomCycleAnalytics|Select start event') },
......@@ -132,8 +127,7 @@ export default {
},
hasErrors() {
return (
this.eventMismatchError ||
Object.values(this.fieldErrors).some(errArray => errArray?.length)
this.eventMismatchError || Object.values(this.errors).some(errArray => errArray?.length)
);
},
isComplete() {
......@@ -162,7 +156,7 @@ export default {
);
},
isDirty() {
return !isEqual(this.initialFields, this.fields) && !isEqual(defaultFields, this.fields);
return !isEqual(this.fields, this.formInitialData || defaultFields);
},
eventMismatchError() {
const {
......@@ -188,35 +182,39 @@ export default {
},
},
watch: {
initialFields(newFields) {
formInitialData(newFields = {}) {
this.fields = {
...defaultFields,
...newFields,
};
},
errors(newErrors) {
this.fieldErrors = {
...defaultFields,
formErrors(newErrors = {}) {
this.errors = {
...newErrors,
};
},
},
mounted() {
this.resetFields();
},
methods: {
handleCancel() {
const { initialFields = {}, errors = null } = this;
const formData = initializeFormData({
emptyFieldState: defaultFields,
initialFields,
errors,
resetFields() {
const { formInitialData, formErrors } = this;
const { fields, errors } = initializeFormData({
fields: formInitialData,
errors: formErrors,
});
this.$set(this, 'fields', formData.fields);
this.$set(this, 'fieldErrors', formData.fieldErrors);
this.fields = { ...fields };
this.errors = { ...errors };
},
handleCancel() {
this.resetFields();
this.$emit('cancel');
},
handleSave() {
const data = convertObjectPropsToSnakeCase(this.fields);
if (this.isEditingCustomStage) {
const { id } = this.initialFields;
const { id } = this.fields;
this.$emit(STAGE_ACTIONS.UPDATE, { ...data, id });
} else {
this.$emit(STAGE_ACTIONS.CREATE, data);
......@@ -229,31 +227,22 @@ export default {
this.fields[key] = null;
},
hasFieldErrors(key) {
return this.fieldErrors[key]?.length > 0;
return this.errors[key]?.length > 0;
},
fieldErrorMessage(key) {
return this.fieldErrors[key]?.join('\n');
return this.errors[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', []);
}
this.errors.name = DEFAULT_STAGE_NAMES.includes(this.fields.name.toLowerCase())
? [ERRORS.STAGE_NAME_EXISTS]
: [];
},
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'),
]);
this.fields.endEventIdentifier = null;
this.errors.endEventIdentifier = [ERRORS.INVALID_EVENT_PAIRS];
},
onUpdateEndEventField() {
this.$set(this.fieldErrors, 'endEventIdentifier', null);
this.errors.endEventIdentifier = [];
},
handleRecoverStage(id) {
this.$emit(STAGE_ACTIONS.UPDATE, { id, hidden: false });
......@@ -262,7 +251,10 @@ export default {
};
</script>
<template>
<form class="custom-stage-form m-4 mt-0">
<div v-if="isLoading">
<gl-loading-icon class="mt-4" size="md" />
</div>
<form v-else class="custom-stage-form m-4 mt-0">
<div class="mb-1 d-flex flex-row justify-content-between">
<h4>{{ formTitle }}</h4>
<gl-dropdown :text="__('Recover hidden stage')" class="js-recover-hidden-stage-dropdown">
......@@ -366,7 +358,6 @@ export default {
</gl-form-group>
</div>
</div>
<div class="custom-stage-form-actions">
<button
:disabled="!isDirty"
......@@ -386,7 +377,6 @@ export default {
{{ saveStageText }}
</button>
</div>
<div class="mt-2">
<gl-sprintf
:message="
......
......@@ -29,10 +29,6 @@ export default {
type: Boolean,
required: true,
},
customStageFormActive: {
type: Boolean,
required: true,
},
canEditStages: {
type: Boolean,
required: true,
......@@ -106,7 +102,7 @@ export default {
<add-stage-button
v-if="canEditStages"
:class="$options.noDragClass"
:active="customStageFormActive"
:active="isCreatingCustomStage"
@showform="$emit('showAddStageForm')"
/>
</ul>
......
......@@ -15,15 +15,18 @@ export const hideForm = ({ commit }) => {
};
export const showCreateForm = ({ commit }) => {
commit(types.SET_LOADING);
commit(types.SET_FORM_INITIAL_DATA);
commit(types.SHOW_CREATE_FORM);
removeFlash();
};
export const showEditForm = ({ commit, dispatch }, selectedStage = {}) => {
commit(types.SET_LOADING);
commit(types.SET_FORM_INITIAL_DATA, selectedStage);
commit(types.SHOW_EDIT_FORM);
dispatch('setSelectedStage', selectedStage, { root: true });
dispatch('clearSavingCustomStage');
commit(types.SHOW_EDIT_FORM);
removeFlash();
};
......
export const SET_LOADING = 'SET_LOADING';
export const SET_STAGE_EVENTS = 'SET_STAGE_EVENTS';
export const SET_STAGE_FORM_ERRORS = 'SET_STAGE_FORM_ERRORS';
export const SET_FORM_INITIAL_DATA = 'SET_FORM_INITIAL_DATA';
export const SET_SAVING_CUSTOM_STAGE = 'SET_SAVING_CUSTOM_STAGE';
export const CLEAR_SAVING_CUSTOM_STAGE = 'CLEAR_SAVING_CUSTOM_STAGE';
export const HIDE_FORM = 'SHOW_FORM';
export const HIDE_FORM = 'HIDE_FORM';
export const SHOW_CREATE_FORM = 'SHOW_CREATE_FORM';
export const SHOW_EDIT_FORM = 'SHOW_EDIT_FORM';
export const CLEAR_FORM_ERRORS = 'CLEAR_FORM_ERRORS';
......
......@@ -31,21 +31,26 @@ export default {
state.formErrors = convertObjectPropsToCamelCase(errors, { deep: true });
},
[types.SET_FORM_INITIAL_DATA](state, rawStageData = null) {
state.formInitialData = extractFormFields(rawStageData);
state.formInitialData = rawStageData ? extractFormFields(rawStageData) : null;
},
[types.SET_SAVING_CUSTOM_STAGE](state) {
state.isSavingCustomStage = true;
},
[types.SET_LOADING](state) {
state.isLoadingCustomStage = true;
},
[types.CLEAR_SAVING_CUSTOM_STAGE](state) {
state.isSavingCustomStage = false;
},
[types.SHOW_CREATE_FORM](state) {
state.isLoadingCustomStage = false;
state.isEditingCustomStage = false;
state.isCreatingCustomStage = true;
state.formInitialData = null;
state.formErrors = null;
},
[types.SHOW_EDIT_FORM](state) {
state.isLoadingCustomStage = false;
state.isCreatingCustomStage = false;
state.isEditingCustomStage = true;
state.formErrors = null;
......
export default () => ({
isLoadingCustomStage: false,
isSavingCustomStage: false,
isCreatingCustomStage: false,
isEditingCustomStage: false,
......
import Vue from 'vue';
import Vuex from 'vuex';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import createStore from 'ee/analytics/cycle_analytics/store';
import customStagesStore from 'ee/analytics/cycle_analytics/store/modules/custom_stages';
import { createLocalVue, mount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import CustomStageForm, {
......@@ -20,7 +19,7 @@ import {
customStageFormErrors,
} from '../mock_data';
const initData = {
const formInitialData = {
id: 74,
name: 'Cool stage pre',
startEventIdentifier: labelStartEvent.identifier,
......@@ -32,16 +31,37 @@ const initData = {
const MERGE_REQUEST_CREATED = 'merge_request_created';
const MERGE_REQUEST_CLOSED = 'merge_request_closed';
let store = null;
const localVue = createLocalVue();
localVue.use(Vuex);
const fakeStore = ({ initialState, initialRootGetters }) =>
new Vuex.Store({
getters: {
currentGroupPath: () => 'fake',
hiddenStages: () => [],
...initialRootGetters,
},
modules: {
customStages: {
...customStagesStore,
state: {
isLoading: false,
...initialState,
},
},
},
});
describe('CustomStageForm', () => {
function createComponent(props = {}, stubs = {}) {
store = createStore();
function createComponent({
initialState = {},
initialRootGetters = {},
stubs = {},
props = {},
} = {}) {
return mount(CustomStageForm, {
localVue,
store,
store: fakeStore({ initialState, initialRootGetters }),
propsData: {
events,
...props,
......@@ -98,7 +118,7 @@ describe('CustomStageForm', () => {
stopEventDropdownIndex = mergeRequestClosedDropdownIndex,
} = {}) {
selectDropdownOption(wrapper, sel.startEvent, startEventDropdownIndex);
return Vue.nextTick().then(() => {
return wrapper.vm.$nextTick().then(() => {
selectDropdownOption(wrapper, sel.endEvent, stopEventDropdownIndex);
});
}
......@@ -117,7 +137,7 @@ describe('CustomStageForm', () => {
beforeEach(() => {
mock = mockGroupLabelsRequest();
wrapper = createComponent({});
wrapper = createComponent();
});
afterEach(() => {
......@@ -155,7 +175,7 @@ describe('CustomStageForm', () => {
describe('Name', () => {
describe('with a reserved name', () => {
beforeEach(() => {
wrapper = createComponent({});
wrapper = createComponent();
return setNameField(wrapper, 'issue');
});
......@@ -174,7 +194,7 @@ describe('CustomStageForm', () => {
describe('Start event', () => {
describe('with events', () => {
beforeEach(() => {
wrapper = createComponent({});
wrapper = createComponent();
});
afterEach(() => {
......@@ -229,7 +249,7 @@ describe('CustomStageForm', () => {
},
});
return Vue.nextTick().then(() => {
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(sel.startEventLabel).exists()).toEqual(true);
});
});
......@@ -246,7 +266,7 @@ describe('CustomStageForm', () => {
.findAll('.dropdown-item')
.at(1) // item at index 0 is 'select a label'
.trigger('click');
return Vue.nextTick();
return wrapper.vm.$nextTick();
})
.then(() => {
expect(wrapper.vm.fields.startEventLabelId).toEqual(selectedLabelId);
......@@ -265,12 +285,14 @@ describe('CustomStageForm', () => {
});
it('notifies that a start event needs to be selected first', () => {
expect(wrapper.text()).toContain('Please select a start event first');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.text()).toContain('Please select a start event first');
});
});
it('clears notification when a start event is selected', () => {
selectDropdownOption(wrapper, sel.startEvent, startEventDropdownIndex);
return Vue.nextTick().then(() => {
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.text()).not.toContain('Please select a start event first');
});
});
......@@ -280,7 +302,7 @@ describe('CustomStageForm', () => {
expect(el.attributes('disabled')).toEqual('disabled');
selectDropdownOption(wrapper, sel.startEvent, startEventDropdownIndex);
return Vue.nextTick().then(() => {
return wrapper.vm.$nextTick().then(() => {
expect(el.attributes('disabled')).toBeUndefined();
});
});
......@@ -292,7 +314,7 @@ describe('CustomStageForm', () => {
selectDropdownOption(wrapper, sel.startEvent, startEventDropdownIndex);
return Vue.nextTick().then(() => {
return wrapper.vm.$nextTick().then(() => {
stopOptions = wrapper.find(sel.endEvent);
selectedStartEvent.allowedEndEvents.forEach(identifier => {
expect(stopOptions.html()).toContain(identifier);
......@@ -308,7 +330,7 @@ describe('CustomStageForm', () => {
selectDropdownOption(wrapper, sel.startEvent, startEventDropdownIndex);
return Vue.nextTick().then(() => {
return wrapper.vm.$nextTick().then(() => {
stopOptions = wrapper.find(sel.endEvent);
possibleEndEvents.forEach(({ name, identifier }) => {
......@@ -325,7 +347,7 @@ describe('CustomStageForm', () => {
selectDropdownOption(wrapper, sel.startEvent, startEventArrayIndex + 1);
return Vue.nextTick().then(() => {
return wrapper.vm.$nextTick().then(() => {
stopOptions = wrapper.find(sel.endEvent);
excludedEndEvents.forEach(({ name, identifier }) => {
......@@ -355,10 +377,16 @@ describe('CustomStageForm', () => {
wrapper.destroy();
});
it('notifies that a start event needs to be selected first', () => {
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.text()).toContain('Please select a start event first');
});
});
it('will notify if the current start and stop event pair is not valid', () => {
selectDropdownOption(wrapper, sel.startEvent, 2);
return Vue.nextTick().then(() => {
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(sel.invalidFeedback).exists()).toEqual(true);
expect(wrapper.find(sel.invalidFeedback).text()).toContain(
'Start event changed, please select a valid stop event',
......@@ -369,14 +397,14 @@ describe('CustomStageForm', () => {
it('will update the list of stop events', () => {
const se = wrapper.vm.endEventOptions;
selectDropdownOption(wrapper, sel.startEvent, 2);
return Vue.nextTick().then(() => {
return wrapper.vm.$nextTick().then(() => {
expect(se[1].value).not.toEqual(wrapper.vm.endEventOptions[1].value);
});
});
it('will disable the submit button until a valid endEvent is selected', () => {
selectDropdownOption(wrapper, sel.startEvent, 2);
return Vue.nextTick().then(() => {
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(sel.submit).attributes('disabled')).toEqual('disabled');
});
});
......@@ -384,7 +412,7 @@ describe('CustomStageForm', () => {
describe('Stop event label', () => {
beforeEach(() => {
wrapper = createComponent({});
wrapper = createComponent();
});
afterEach(() => {
......@@ -405,7 +433,7 @@ describe('CustomStageForm', () => {
},
});
return Vue.nextTick().then(() => {
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(sel.endEventLabel).exists()).toEqual(true);
});
});
......@@ -429,7 +457,7 @@ describe('CustomStageForm', () => {
.at(2) // item at index 0 is 'select a label'
.trigger('click');
return Vue.nextTick();
return wrapper.vm.$nextTick();
})
.then(() => {
expect(wrapper.vm.fields.endEventLabelId).toEqual(selectedLabelId);
......@@ -440,7 +468,7 @@ describe('CustomStageForm', () => {
describe('Add stage button', () => {
beforeEach(() => {
wrapper = createComponent({});
wrapper = createComponent();
});
afterEach(() => {
......@@ -468,11 +496,11 @@ describe('CustomStageForm', () => {
const stopEventDropdownIndex = 1;
beforeEach(() => {
wrapper = createComponent({});
wrapper = createComponent();
wrapper.find(sel.name).setValue('Cool stage');
return Vue.nextTick().then(() =>
setEventDropdowns({ startEventDropdownIndex, stopEventDropdownIndex }),
);
return wrapper.vm
.$nextTick()
.then(() => setEventDropdowns({ startEventDropdownIndex, stopEventDropdownIndex }));
});
afterEach(() => {
......@@ -485,7 +513,7 @@ describe('CustomStageForm', () => {
wrapper.find(sel.submit).trigger('click');
return Vue.nextTick().then(() => {
return wrapper.vm.$nextTick().then(() => {
event = findEvent(STAGE_ACTIONS.CREATE);
expect(event).toBeTruthy();
expect(event).toHaveLength(1);
......@@ -510,7 +538,7 @@ describe('CustomStageForm', () => {
];
wrapper.find(sel.submit).trigger('click');
return Vue.nextTick().then(() => {
return wrapper.vm.$nextTick().then(() => {
event = findEvent(STAGE_ACTIONS.CREATE);
expect(event[0]).toEqual(res);
});
......@@ -520,7 +548,7 @@ describe('CustomStageForm', () => {
describe('Cancel button', () => {
beforeEach(() => {
wrapper = createComponent({});
wrapper = createComponent();
});
afterEach(() => {
......@@ -533,7 +561,7 @@ describe('CustomStageForm', () => {
expect(btn.attributes('disabled')).toEqual('disabled');
wrapper.find(sel.name).setValue('Cool stage');
return Vue.nextTick().then(() => {
return wrapper.vm.$nextTick().then(() => {
expect(btn.attributes('disabled')).toBeUndefined();
});
});
......@@ -547,11 +575,12 @@ describe('CustomStageForm', () => {
},
});
return Vue.nextTick()
return wrapper.vm
.$nextTick()
.then(() => {
wrapper.find(sel.cancel).trigger('click');
return Vue.nextTick();
return wrapper.vm.$nextTick();
})
.then(() => {
expect(wrapper.vm.fields).toEqual({
......@@ -575,10 +604,11 @@ describe('CustomStageForm', () => {
},
});
return Vue.nextTick()
return wrapper.vm
.$nextTick()
.then(() => {
wrapper.find(sel.cancel).trigger('click');
return Vue.nextTick();
return wrapper.vm.$nextTick();
})
.then(() => {
ev = findEvent('cancel');
......@@ -590,12 +620,12 @@ describe('CustomStageForm', () => {
describe('isSavingCustomStage=true', () => {
beforeEach(() => {
wrapper = createComponent(
{
wrapper = createComponent({
initialState: {
isSavingCustomStage: true,
},
false,
);
});
return wrapper.vm.$nextTick();
});
it('displays a loading icon', () => {
......@@ -606,19 +636,13 @@ describe('CustomStageForm', () => {
describe('Editing a custom stage', () => {
beforeEach(() => {
wrapper = createComponent({
isEditingCustomStage: true,
initialFields: {
...initData,
},
});
wrapper.setData({
fields: {
...initData,
initialState: {
isEditingCustomStage: true,
formInitialData,
},
});
return Vue.nextTick();
return wrapper.vm.$nextTick();
});
afterEach(() => {
......@@ -635,13 +659,14 @@ describe('CustomStageForm', () => {
},
});
return Vue.nextTick()
return wrapper.vm
.$nextTick()
.then(() => {
wrapper.find(sel.cancel).trigger('click');
return Vue.nextTick();
return wrapper.vm.$nextTick();
})
.then(() => {
expect(wrapper.vm.fields).toEqual({ ...initData });
expect(wrapper.vm.fields).toEqual({ ...formInitialData });
});
});
});
......@@ -662,7 +687,7 @@ describe('CustomStageForm', () => {
},
});
return Vue.nextTick().then(() => {
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(sel.submit).attributes('disabled')).toBeUndefined();
});
});
......@@ -674,7 +699,7 @@ describe('CustomStageForm', () => {
},
});
return Vue.nextTick().then(() => {
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(sel.submit).attributes('disabled')).toEqual('disabled');
});
});
......@@ -689,10 +714,11 @@ describe('CustomStageForm', () => {
},
});
return Vue.nextTick()
return wrapper.vm
.$nextTick()
.then(() => {
wrapper.find(sel.submit).trigger('click');
return Vue.nextTick();
return wrapper.vm.$nextTick();
})
.then(() => {
ev = findEvent(STAGE_ACTIONS.UPDATE);
......@@ -708,17 +734,18 @@ describe('CustomStageForm', () => {
},
});
return Vue.nextTick()
return wrapper.vm
.$nextTick()
.then(() => {
wrapper.find(sel.submit).trigger('click');
return Vue.nextTick();
return wrapper.vm.$nextTick();
})
.then(() => {
const submitted = findEvent(STAGE_ACTIONS.UPDATE)[0];
expect(submitted).not.toEqual([initData]);
expect(submitted).not.toEqual([formInitialData]);
expect(submitted).toEqual([
{
id: initData.id,
id: formInitialData.id,
start_event_identifier: labelStartEvent.identifier,
start_event_label_id: groupLabels[0].id,
end_event_identifier: labelStopEvent.identifier,
......@@ -733,11 +760,10 @@ describe('CustomStageForm', () => {
describe('isSavingCustomStage=true', () => {
beforeEach(() => {
wrapper = createComponent({
isEditingCustomStage: true,
initialFields: {
...initData,
initialState: {
isEditingCustomStage: true,
isSavingCustomStage: true,
},
isSavingCustomStage: true,
});
});
it('displays a loading icon', () => {
......@@ -749,11 +775,12 @@ describe('CustomStageForm', () => {
describe('With errors', () => {
beforeEach(() => {
wrapper = createComponent({
initialFields: initData,
errors: customStageFormErrors,
initialState: {
formErrors: customStageFormErrors,
},
});
return Vue.nextTick();
return wrapper.vm.$nextTick();
});
afterEach(() => {
......@@ -775,7 +802,9 @@ describe('CustomStageForm', () => {
};
beforeEach(() => {
wrapper = createComponent({}, formFieldStubs);
wrapper = createComponent({
stubs: formFieldStubs,
});
});
describe('without hidden stages', () => {
......@@ -795,8 +824,12 @@ describe('CustomStageForm', () => {
describe('with hidden stages', () => {
beforeEach(() => {
wrapper = createComponent({}, formFieldStubs);
store.state.stages = [{ id: 'my-stage', title: 'My default stage', hidden: true }];
wrapper = createComponent({
stubs: formFieldStubs,
initialRootGetters: {
hiddenStages: () => [{ id: 'my-stage', title: 'My default stage', hidden: true }],
},
});
});
it('has stages available to recover', () => {
......@@ -825,26 +858,46 @@ describe('CustomStageForm', () => {
});
describe('initializeFormData', () => {
const emptyFieldState = {
id: null,
name: null,
startEventIdentifier: null,
startEventLabelId: null,
endEventIdentifier: null,
endEventLabelId: null,
};
const emptyErrorsState = {
id: [],
name: [],
startEventIdentifier: [],
startEventLabelId: [],
endEventIdentifier: [],
endEventLabelId: [],
};
describe('without a startEventIdentifier', () => {
it('with no errors', () => {
const res = initializeFormData({
initialFields: {},
emptyFieldState,
fields: {},
});
expect(res.fields).toEqual({});
expect(res.fieldErrors).toEqual({
expect(res.fields).toEqual(emptyFieldState);
expect(res.errors).toMatchObject({
endEventIdentifier: ['Please select a start event first'],
});
});
it('with field errors', () => {
const res = initializeFormData({
initialFields: {},
emptyFieldState,
fields: {},
errors: {
name: ['is reserved'],
},
});
expect(res.fields).toEqual({});
expect(res.fieldErrors).toEqual({
expect(res.fields).toEqual(emptyFieldState);
expect(res.errors).toMatchObject({
endEventIdentifier: ['Please select a start event first'],
name: ['is reserved'],
});
......@@ -854,29 +907,31 @@ describe('CustomStageForm', () => {
describe('with a startEventIdentifier', () => {
it('with no errors', () => {
const res = initializeFormData({
initialFields: {
emptyFieldState,
fields: {
startEventIdentifier: 'start-event',
},
errors: {},
});
expect(res.fields).toEqual({ startEventIdentifier: 'start-event' });
expect(res.fieldErrors).toEqual({
endEventIdentifier: null,
expect(res.fields).toEqual({ ...emptyFieldState, startEventIdentifier: 'start-event' });
expect(res.errors).toMatchObject({
endEventIdentifier: [],
});
});
it('with field errors', () => {
const res = initializeFormData({
initialFields: {
emptyFieldState,
fields: {
startEventIdentifier: 'start-event',
},
errors: {
name: ['is reserved'],
},
});
expect(res.fields).toEqual({ startEventIdentifier: 'start-event' });
expect(res.fieldErrors).toEqual({
endEventIdentifier: null,
expect(res.fields).toEqual({ ...emptyFieldState, startEventIdentifier: 'start-event' });
expect(res.errors).toMatchObject({
endEventIdentifier: [],
name: ['is reserved'],
});
});
......@@ -885,7 +940,8 @@ describe('CustomStageForm', () => {
describe('with all fields set', () => {
it('with no errors', () => {
const res = initializeFormData({
initialFields: {
emptyFieldState,
fields: {
id: 1,
name: 'cool-stage',
startEventIdentifier: 'start-event',
......@@ -903,14 +959,13 @@ describe('CustomStageForm', () => {
startEventLabelId: 10,
endEventLabelId: 20,
});
expect(res.fieldErrors).toEqual({
endEventIdentifier: null,
});
expect(res.errors).toEqual(emptyErrorsState);
});
it('with field errors', () => {
const res = initializeFormData({
initialFields: {
emptyFieldState,
fields: {
id: 1,
name: 'cool-stage',
startEventIdentifier: 'start-event',
......@@ -922,6 +977,7 @@ describe('CustomStageForm', () => {
name: ['is reserved'],
},
});
expect(res.fields).toEqual({
id: 1,
name: 'cool-stage',
......@@ -930,8 +986,7 @@ describe('CustomStageForm', () => {
startEventLabelId: 10,
endEventLabelId: 20,
});
expect(res.fieldErrors).toEqual({
endEventIdentifier: null,
expect(res.errors).toMatchObject({
name: ['is reserved'],
});
});
......
......@@ -130,8 +130,8 @@ export const rawCustomStage = {
export const medians = stageMedians;
const { events: rawCustomStageEvents } = customizableStagesAndEvents;
const camelCasedStageEvents = rawCustomStageEvents.map(deepCamelCase);
export const rawCustomStageEvents = customizableStagesAndEvents.events;
export const camelCasedStageEvents = rawCustomStageEvents.map(deepCamelCase);
export const customStageLabelEvents = camelCasedStageEvents.filter(ev => ev.type === 'label');
export const customStageStartEvents = camelCasedStageEvents.filter(ev => ev.canBeStartEvent);
......
......@@ -4,8 +4,6 @@ import testAction from 'helpers/vuex_action_helper';
import * as getters from 'ee/analytics/cycle_analytics/store/getters';
import * as actions from 'ee/analytics/cycle_analytics/store/actions';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import * as customStageActions from 'ee/analytics/cycle_analytics/store/modules/custom_stages/actions';
import * as customStageTypes from 'ee/analytics/cycle_analytics/store/modules/custom_stages/mutation_types';
import createFlash from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status';
import {
......@@ -684,136 +682,6 @@ describe('Cycle analytics actions', () => {
});
});
describe('createStage', () => {
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 'receiveCreateStageSuccess' action`, () =>
testAction(
customStageActions.createStage,
customStageData,
state,
[],
[
{ type: 'clearFormErrors' },
{ type: 'setSavingCustomStage' },
{
type: 'receiveCreateStageSuccess',
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 'receiveCreateStageError' action`, () =>
testAction(
customStageActions.createStage,
customStageData,
state,
[],
[
{ type: 'clearFormErrors' },
{ type: 'setSavingCustomStage' },
{
type: 'receiveCreateStageError',
payload: {
data: customStageData,
errors,
message,
status: httpStatusCodes.UNPROCESSABLE_ENTITY,
},
},
],
));
});
});
describe('receiveCreateStageError', () => {
const response = {
data: { name: 'uh oh' },
};
beforeEach(() => {});
it('will commit the RECEIVE_CREATE_STAGE_ERROR mutation', () =>
testAction(
customStageActions.receiveCreateStageError,
response,
state,
[{ type: customStageTypes.RECEIVE_CREATE_STAGE_ERROR }],
[
{
type: 'setStageFormErrors',
payload: {},
},
],
));
it('will flash an error message', () => {
return customStageActions
.receiveCreateStageError(
{
dispatch: () => Promise.resolve(),
commit: () => {},
},
response,
)
.then(() => {
shouldFlashAMessage('There was a problem saving your custom stage, please try again');
});
});
describe('with a stage name error', () => {
it('will flash an error message', () => {
return customStageActions
.receiveCreateStageError(
{
dispatch: () => Promise.resolve(),
commit: () => {},
},
{
...response,
status: httpStatusCodes.UNPROCESSABLE_ENTITY,
errors: { name: ['is reserved'] },
},
)
.then(() => {
shouldFlashAMessage("'uh oh' stage already exists");
});
});
});
});
describe('initializeCycleAnalytics', () => {
let mockDispatch;
let mockCommit;
......@@ -873,38 +741,6 @@ describe('Cycle analytics actions', () => {
));
});
describe('receiveCreateStageSuccess', () => {
const response = {
data: {
title: 'COOL',
},
};
it('will dispatch fetchGroupStagesAndEvents', () =>
testAction(
customStageActions.receiveCreateStageSuccess,
response,
state,
[{ type: customStageTypes.RECEIVE_CREATE_STAGE_SUCCESS }],
[{ type: 'fetchGroupStagesAndEvents', payload: null }, { type: 'clearSavingCustomStage' }],
));
describe('with an error', () => {
it('will flash an error message', () =>
customStageActions
.receiveCreateStageSuccess(
{
dispatch: () => Promise.reject(),
commit: () => {},
},
response,
)
.then(() => {
shouldFlashAMessage('There was a problem refreshing the data, please try again');
}));
});
});
describe('reorderStage', () => {
const stageId = 'cool-stage';
const payload = { id: stageId, move_after_id: '2', move_before_id: '8' };
......
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import * as actions from 'ee/analytics/cycle_analytics/store/modules/custom_stages/actions';
import * as types from 'ee/analytics/cycle_analytics/store/modules/custom_stages/mutation_types';
import createFlash from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status';
import { selectedGroup, endpoints, rawCustomStage } from '../../../mock_data';
jest.mock('~/flash');
describe('Custom stage actions', () => {
let state;
let mock;
const selectedStage = rawCustomStage;
const shouldFlashAMessage = (msg, type = null) => {
const args = type ? [msg, type] : [msg];
expect(createFlash).toHaveBeenCalledWith(...args);
};
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
state = { selectedGroup: null };
});
describe('createStage', () => {
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 'receiveCreateStageSuccess' action`, () =>
testAction(
actions.createStage,
customStageData,
state,
[],
[
{ type: 'clearFormErrors' },
{ type: 'setSavingCustomStage' },
{
type: 'receiveCreateStageSuccess',
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 'receiveCreateStageError' action`, () =>
testAction(
actions.createStage,
customStageData,
state,
[],
[
{ type: 'clearFormErrors' },
{ type: 'setSavingCustomStage' },
{
type: 'receiveCreateStageError',
payload: {
data: customStageData,
errors,
message,
status: httpStatusCodes.UNPROCESSABLE_ENTITY,
},
},
],
));
});
});
describe('receiveCreateStageError', () => {
const response = {
data: { name: 'uh oh' },
};
beforeEach(() => {});
it('will commit the RECEIVE_CREATE_STAGE_ERROR mutation', () =>
testAction(
actions.receiveCreateStageError,
response,
state,
[{ type: types.RECEIVE_CREATE_STAGE_ERROR }],
[{ type: 'setStageFormErrors', payload: {} }],
));
it('will flash an error message', () => {
return actions
.receiveCreateStageError(
{
dispatch: () => Promise.resolve(),
commit: () => {},
},
response,
)
.then(() => {
shouldFlashAMessage('There was a problem saving your custom stage, please try again');
});
});
describe('with a stage name error', () => {
it('will flash an error message', () => {
return actions
.receiveCreateStageError(
{
dispatch: () => Promise.resolve(),
commit: () => {},
},
{
...response,
status: httpStatusCodes.UNPROCESSABLE_ENTITY,
errors: { name: ['is reserved'] },
},
)
.then(() => {
shouldFlashAMessage("'uh oh' stage already exists");
});
});
});
});
describe('receiveCreateStageSuccess', () => {
const response = {
data: {
title: 'COOL',
},
};
it('will dispatch fetchGroupStagesAndEvents', () =>
testAction(
actions.receiveCreateStageSuccess,
response,
state,
[{ type: types.RECEIVE_CREATE_STAGE_SUCCESS }],
[{ type: 'fetchGroupStagesAndEvents', payload: null }, { type: 'clearSavingCustomStage' }],
));
describe('with an error', () => {
it('will flash an error message', () =>
actions
.receiveCreateStageSuccess(
{
dispatch: () => Promise.reject(),
commit: () => {},
},
response,
)
.then(() => {
shouldFlashAMessage('There was a problem refreshing the data, please try again');
}));
});
});
describe('setStageFormErrors', () => {
it('commits the "SET_STAGE_FORM_ERRORS" mutation', () => {
return testAction(
actions.setStageFormErrors,
[],
state,
[{ type: types.SET_STAGE_FORM_ERRORS, payload: [] }],
[],
);
});
});
describe('clearFormErrors', () => {
it('commits the "CLEAR_FORM_ERRORS" mutation', () => {
return testAction(
actions.clearFormErrors,
[],
state,
[{ type: types.CLEAR_FORM_ERRORS }],
[],
);
});
});
describe('setStageEvents', () => {
it('commits the "SET_STAGE_EVENTS" mutation', () => {
return testAction(
actions.setStageEvents,
[],
state,
[{ type: types.SET_STAGE_EVENTS, payload: [] }],
[],
);
});
});
describe('hideForm', () => {
it('commits the "HIDE_FORM" mutation', () => {
return testAction(actions.hideForm, null, state, [{ type: types.HIDE_FORM }], []);
});
});
describe('showCreateForm', () => {
it('commits the "SHOW_CREATE_FORM" mutation', () => {
return testAction(
actions.showCreateForm,
null,
state,
[
{ type: types.SET_LOADING },
{ type: types.SET_FORM_INITIAL_DATA },
{ type: types.SHOW_CREATE_FORM },
],
[],
);
});
});
describe('showEditForm', () => {
it('commits the "SHOW_EDIT_FORM" mutation with initial data', () => {
return testAction(
actions.showEditForm,
selectedStage,
state,
[
{ type: types.SET_LOADING },
{ type: types.SET_FORM_INITIAL_DATA, payload: rawCustomStage },
{ type: types.SHOW_EDIT_FORM },
],
[{ type: 'setSelectedStage', payload: rawCustomStage }, { type: 'clearSavingCustomStage' }],
);
});
});
});
import * as getters from 'ee/analytics/cycle_analytics/store/modules/custom_stages/getters';
describe('Custom stages getters', () => {
describe.each`
state | result
${{ isCreatingCustomStage: true, isEditingCustomStage: true }} | ${true}
${{ isCreatingCustomStage: false, isEditingCustomStage: true }} | ${true}
${{ isCreatingCustomStage: true, isEditingCustomStage: false }} | ${true}
${{ isCreatingCustomStage: false, isEditingCustomStage: false }} | ${false}
`('customStageFormActive', ({ state, result }) => {
it(`with state ${state} returns ${result}`, () => {
expect(getters.customStageFormActive(state)).toEqual(result);
});
});
});
import mutations from 'ee/analytics/cycle_analytics/store/modules/custom_stages/mutations';
import * as types from 'ee/analytics/cycle_analytics/store/modules/custom_stages/mutation_types';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { rawCustomStageEvents, camelCasedStageEvents, rawCustomStage } from '../../../mock_data';
let state = null;
describe('Custom stage mutations', () => {
beforeEach(() => {
state = {};
});
afterEach(() => {
state = null;
});
it.each`
mutation | stateKey | value
${types.HIDE_FORM} | ${'isCreatingCustomStage'} | ${false}
${types.HIDE_FORM} | ${'isEditingCustomStage'} | ${false}
${types.HIDE_FORM} | ${'formErrors'} | ${null}
${types.HIDE_FORM} | ${'formInitialData'} | ${null}
${types.CLEAR_FORM_ERRORS} | ${'formErrors'} | ${null}
${types.SHOW_CREATE_FORM} | ${'isCreatingCustomStage'} | ${true}
${types.SHOW_CREATE_FORM} | ${'isEditingCustomStage'} | ${false}
${types.SHOW_CREATE_FORM} | ${'formErrors'} | ${null}
${types.SHOW_CREATE_FORM} | ${'formInitialData'} | ${null}
${types.SHOW_EDIT_FORM} | ${'isEditingCustomStage'} | ${true}
${types.SHOW_EDIT_FORM} | ${'isCreatingCustomStage'} | ${false}
${types.SHOW_EDIT_FORM} | ${'formErrors'} | ${null}
${types.RECEIVE_CREATE_STAGE_SUCCESS} | ${'formErrors'} | ${null}
${types.RECEIVE_CREATE_STAGE_SUCCESS} | ${'formInitialData'} | ${null}
${types.RECEIVE_CREATE_STAGE_ERROR} | ${'isSavingCustomStage'} | ${false}
${types.SET_SAVING_CUSTOM_STAGE} | ${'isSavingCustomStage'} | ${true}
${types.CLEAR_SAVING_CUSTOM_STAGE} | ${'isSavingCustomStage'} | ${false}
${types.SET_LOADING} | ${'isLoadingCustomStage'} | ${true}
`('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => {
mutations[mutation](state);
expect(state[stateKey]).toEqual(value);
});
describe(`${types.SET_STAGE_EVENTS}`, () => {
it('will set formEvents', () => {
state = {};
mutations[types.SET_STAGE_EVENTS](state, rawCustomStageEvents);
expect(state.formEvents).toEqual(camelCasedStageEvents);
});
});
describe(`${types.SET_STAGE_FORM_ERRORS}`, () => {
const mockFormError = { start_identifier: ['Cant be blank'] };
it('will set formErrors', () => {
state = {};
mutations[types.SET_STAGE_FORM_ERRORS](state, mockFormError);
expect(state.formErrors).toEqual(convertObjectPropsToCamelCase(mockFormError));
});
});
describe(`${types.SET_FORM_INITIAL_DATA}`, () => {
const mockStage = {
endEventIdentifier: 'issue_first_added_to_board',
endEventLabelId: null,
id: 18,
name: 'Coolest beans stage',
startEventIdentifier: 'issue_first_mentioned_in_commit',
startEventLabelId: null,
};
it('will set formInitialData', () => {
state = {};
mutations[types.SET_FORM_INITIAL_DATA](state, rawCustomStage);
expect(state.formInitialData).toEqual(mockStage);
});
});
});
import mutations from 'ee/analytics/cycle_analytics/store/mutations';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import customStageMutations from 'ee/analytics/cycle_analytics/store/modules/custom_stages/mutations';
import * as customStageTypes from 'ee/analytics/cycle_analytics/store/modules/custom_stages/mutation_types';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import {
issueStage,
......@@ -13,9 +10,8 @@ import {
totalStage,
startDate,
endDate,
customizableStagesAndEvents,
selectedProjects,
rawCustomStage,
customizableStagesAndEvents,
} from '../mock_data';
let state = null;
......@@ -70,65 +66,6 @@ describe('Cycle analytics mutations', () => {
},
);
describe('Custom stage mutations', () => {
beforeEach(() => {
state = {};
});
afterEach(() => {
state = null;
});
it.each`
mutation | stateKey | value
${customStageTypes.HIDE_FORM} | ${'isCreatingCustomStage'} | ${false}
${customStageTypes.HIDE_FORM} | ${'isEditingCustomStage'} | ${false}
${customStageTypes.HIDE_FORM} | ${'formErrors'} | ${null}
${customStageTypes.HIDE_FORM} | ${'formInitialData'} | ${null}
${customStageTypes.SHOW_CREATE_FORM} | ${'isCreatingCustomStage'} | ${true}
${customStageTypes.SHOW_CREATE_FORM} | ${'isEditingCustomStage'} | ${false}
${customStageTypes.SHOW_CREATE_FORM} | ${'formErrors'} | ${null}
${customStageTypes.SHOW_EDIT_FORM} | ${'isEditingCustomStage'} | ${true}
${customStageTypes.SHOW_EDIT_FORM} | ${'isCreatingCustomStage'} | ${false}
${customStageTypes.SHOW_EDIT_FORM} | ${'formErrors'} | ${null}
${customStageTypes.RECEIVE_CREATE_STAGE_ERROR} | ${'isSavingCustomStage'} | ${false}
${customStageTypes.SET_SAVING_CUSTOM_STAGE} | ${'isSavingCustomStage'} | ${true}
${customStageTypes.CLEAR_SAVING_CUSTOM_STAGE} | ${'isSavingCustomStage'} | ${false}
`('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => {
customStageMutations[mutation](state);
expect(state[stateKey]).toEqual(value);
});
describe(`${customStageTypes.SET_STAGE_FORM_ERRORS}`, () => {
const mockFormError = { start_identifier: ['Cant be blank'] };
it('will set formErrors', () => {
state = {};
customStageMutations[customStageTypes.SET_STAGE_FORM_ERRORS](state, mockFormError);
expect(state.formErrors).toEqual(convertObjectPropsToCamelCase(mockFormError));
});
});
describe(`${customStageTypes.SET_FORM_INITIAL_DATA}`, () => {
const mockStage = {
id: 18,
name: 'Coolest beans stage',
startEventIdentifier: 'issue_first_mentioned_in_commit',
startEventLabelId: null,
endEventIdentifier: 'issue_first_added_to_board',
endEventLabelId: null,
};
it('will set formInitialData', () => {
state = {};
customStageMutations[customStageTypes.SET_FORM_INITIAL_DATA](state, rawCustomStage);
expect(state.formInitialData).toEqual(mockStage);
});
});
});
describe(`${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS}`, () => {
it('will set isLoading=false and errorCode=null', () => {
mutations[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, {
......
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