Commit bc8ea9a8 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '297370-persist-value-stream-from-defaults' into 'master'

Persist value stream stages

See merge request gitlab-org/gitlab!51884
parents 08ce2b85 98fb79db
...@@ -71,13 +71,7 @@ export const defaultFields = { ...@@ -71,13 +71,7 @@ export const defaultFields = {
endEventLabelId: null, endEventLabelId: null,
}; };
const defaultStageCommonFields = { custom: false, hidden: false }; export const defaultCustomStageFields = { ...defaultFields, custom: true };
export const defaultCustomStageFields = {
...defaultFields,
...defaultStageCommonFields,
custom: true,
};
/** /**
* These stage configs are copied from the https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/cycle_analytics * These stage configs are copied from the https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/cycle_analytics
...@@ -120,11 +114,12 @@ const BASE_DEFAULT_STAGE_CONFIG = [ ...@@ -120,11 +114,12 @@ const BASE_DEFAULT_STAGE_CONFIG = [
export const DEFAULT_STAGE_CONFIG = BASE_DEFAULT_STAGE_CONFIG.map(({ id, ...rest }) => ({ export const DEFAULT_STAGE_CONFIG = BASE_DEFAULT_STAGE_CONFIG.map(({ id, ...rest }) => ({
...rest, ...rest,
...defaultStageCommonFields, custom: false,
name: capitalizeFirstCharacter(id), name: capitalizeFirstCharacter(id),
})); }));
export const PRESET_OPTIONS_DEFAULT = 'default'; export const PRESET_OPTIONS_DEFAULT = 'default';
export const PRESET_OPTIONS_BLANK = 'blank';
export const PRESET_OPTIONS = [ export const PRESET_OPTIONS = [
{ {
text: I18N.TEMPLATE_DEFAULT, text: I18N.TEMPLATE_DEFAULT,
...@@ -132,6 +127,6 @@ export const PRESET_OPTIONS = [ ...@@ -132,6 +127,6 @@ export const PRESET_OPTIONS = [
}, },
{ {
text: I18N.TEMPLATE_BLANK, text: I18N.TEMPLATE_BLANK,
value: 'blank', value: PRESET_OPTIONS_BLANK,
}, },
]; ];
...@@ -32,7 +32,7 @@ export default { ...@@ -32,7 +32,7 @@ export default {
errors: { errors: {
type: Object, type: Object,
required: false, required: false,
default: () => {}, default: () => ({}),
}, },
stageEvents: { stageEvents: {
type: Array, type: Array,
......
...@@ -40,7 +40,7 @@ export default { ...@@ -40,7 +40,7 @@ export default {
errors: { errors: {
type: Object, type: Object,
required: false, required: false,
default: () => {}, default: () => ({}),
}, },
stageEvents: { stageEvents: {
type: Array, type: Array,
...@@ -49,10 +49,10 @@ export default { ...@@ -49,10 +49,10 @@ export default {
}, },
methods: { methods: {
isValid(field) { isValid(field) {
return !this.errors[field]?.length; return !this.errors[field] || !this.errors[field]?.length;
}, },
renderError(field) { renderError(field) {
return this.errors[field]?.join('\n'); return this.errors[field] ? this.errors[field]?.join('\n') : null;
}, },
eventName(eventIds = []) { eventName(eventIds = []) {
return eventIdsToName(this.stageEvents, eventIds); return eventIdsToName(this.stageEvents, eventIds);
...@@ -68,6 +68,7 @@ export default { ...@@ -68,6 +68,7 @@ export default {
class="gl-flex-grow-1 gl-mb-0" class="gl-flex-grow-1 gl-mb-0"
:state="isValid('name')" :state="isValid('name')"
:invalid-feedback="renderError('name')" :invalid-feedback="renderError('name')"
:data-testid="`default-stage-name-${index}`"
> >
<!-- eslint-disable vue/no-mutating-props --> <!-- eslint-disable vue/no-mutating-props -->
<gl-form-input <gl-form-input
......
...@@ -88,20 +88,16 @@ export const validateStage = (fields) => { ...@@ -88,20 +88,16 @@ export const validateStage = (fields) => {
if (fields?.name) { if (fields?.name) {
if (fields.name.length > NAME_MAX_LENGTH) { if (fields.name.length > NAME_MAX_LENGTH) {
newErrors.name = [ERRORS.MAX_LENGTH]; newErrors.name = [ERRORS.MAX_LENGTH];
} else { }
newErrors.name = if (fields?.custom && DEFAULT_STAGE_NAMES.includes(fields.name.toLowerCase())) {
fields?.custom && DEFAULT_STAGE_NAMES.includes(fields.name.toLowerCase()) newErrors.name = [ERRORS.STAGE_NAME_EXISTS];
? [ERRORS.STAGE_NAME_EXISTS]
: [];
} }
} else { } else {
newErrors.name = [ERRORS.STAGE_NAME_MIN_LENGTH]; newErrors.name = [ERRORS.STAGE_NAME_MIN_LENGTH];
} }
if (fields?.startEventIdentifier) { if (fields?.startEventIdentifier) {
if (fields?.endEventIdentifier) { if (!fields?.endEventIdentifier) {
newErrors.endEventIdentifier = [];
} else {
newErrors.endEventIdentifier = [ERRORS.END_EVENT_REQUIRED]; newErrors.endEventIdentifier = [ERRORS.END_EVENT_REQUIRED];
} }
} else { } else {
...@@ -115,15 +111,15 @@ export const validateStage = (fields) => { ...@@ -115,15 +111,15 @@ export const validateStage = (fields) => {
* returned as an array in a object with key`name` * returned as an array in a object with key`name`
* *
* @param {Object} fields key value pair of form field values * @param {Object} fields key value pair of form field values
* @returns {Object} key value pair of form fields with an array of errors * @returns {Array} an array of errors
*/ */
export const validateValueStreamName = ({ name = '' }) => { export const validateValueStreamName = ({ name = '' }) => {
const errors = { name: [] }; const errors = [];
if (name.length > NAME_MAX_LENGTH) { if (name.length > NAME_MAX_LENGTH) {
errors.name.push(ERRORS.MAX_LENGTH); errors.push(ERRORS.MAX_LENGTH);
} }
if (!name.length) { if (!name.length) {
errors.name.push(ERRORS.VALUE_STREAM_NAME_MIN_LENGTH); errors.push(ERRORS.VALUE_STREAM_NAME_MIN_LENGTH);
} }
return errors; return errors;
}; };
<script> <script>
import Vue from 'vue';
import { mapGetters, mapState } from 'vuex'; import { mapGetters, mapState } from 'vuex';
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { import {
...@@ -163,7 +164,7 @@ export default { ...@@ -163,7 +164,7 @@ export default {
this.fields.startEventIdentifier && this.eventMismatchError this.fields.startEventIdentifier && this.eventMismatchError
? [ERRORS.INVALID_EVENT_PAIRS] ? [ERRORS.INVALID_EVENT_PAIRS]
: newErrors.endEventIdentifier; : newErrors.endEventIdentifier;
this.errors = { ...this.errors, ...newErrors }; Vue.set(this, 'errors', newErrors);
}, },
}, },
I18N, I18N,
......
...@@ -3,6 +3,7 @@ import Vue from 'vue'; ...@@ -3,6 +3,7 @@ import Vue from 'vue';
import { GlButton, GlForm, GlFormInput, GlFormGroup, GlFormRadioGroup, GlModal } from '@gitlab/ui'; import { GlButton, GlForm, GlFormInput, GlFormGroup, GlFormRadioGroup, GlModal } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { sprintf } from '~/locale'; import { sprintf } from '~/locale';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import { swapArrayItems } from '~/lib/utils/array_utility'; import { swapArrayItems } from '~/lib/utils/array_utility';
import { import {
DEFAULT_STAGE_CONFIG, DEFAULT_STAGE_CONFIG,
...@@ -16,12 +17,24 @@ import { validateValueStreamName, validateStage } from './create_value_stream_fo ...@@ -16,12 +17,24 @@ import { validateValueStreamName, validateStage } from './create_value_stream_fo
import DefaultStageFields from './create_value_stream_form/default_stage_fields.vue'; import DefaultStageFields from './create_value_stream_form/default_stage_fields.vue';
import CustomStageFields from './create_value_stream_form/custom_stage_fields.vue'; import CustomStageFields from './create_value_stream_form/custom_stage_fields.vue';
const findStageIndexByName = (stages, target = '') =>
stages.findIndex(({ name }) => name === target);
const initializeStageErrors = (selectedPreset = PRESET_OPTIONS_DEFAULT) => const initializeStageErrors = (selectedPreset = PRESET_OPTIONS_DEFAULT) =>
selectedPreset === PRESET_OPTIONS_DEFAULT ? DEFAULT_STAGE_CONFIG.map(() => ({})) : [{}]; selectedPreset === PRESET_OPTIONS_DEFAULT ? DEFAULT_STAGE_CONFIG.map(() => ({})) : [{}];
const initializeStages = (selectedPreset = PRESET_OPTIONS_DEFAULT) =>
selectedPreset === PRESET_OPTIONS_DEFAULT
? DEFAULT_STAGE_CONFIG
: [{ ...defaultCustomStageFields }];
const formatStageDataForSubmission = (stages) => {
return stages.map(({ custom = false, name, ...rest }) => {
return custom
? convertObjectPropsToSnakeCase({ ...rest, custom, name })
: {
name,
};
});
};
export default { export default {
name: 'ValueStreamForm', name: 'ValueStreamForm',
components: { components: {
...@@ -40,6 +53,16 @@ export default { ...@@ -40,6 +53,16 @@ export default {
required: false, required: false,
default: () => ({}), default: () => ({}),
}, },
initialPreset: {
type: String,
required: false,
default: PRESET_OPTIONS_DEFAULT,
},
initialFormErrors: {
type: Object,
required: false,
default: () => ({}),
},
hasExtendedFormFields: { hasExtendedFormFields: {
type: Boolean, type: Boolean,
required: false, required: false,
...@@ -47,53 +70,48 @@ export default { ...@@ -47,53 +70,48 @@ export default {
}, },
}, },
data() { data() {
const { hasExtendedFormFields, initialData } = this; const { hasExtendedFormFields, initialData, initialFormErrors, initialPreset } = this;
const { name: nameError = [], stages: stageErrors = [{}] } = initialFormErrors;
const additionalFields = hasExtendedFormFields const additionalFields = hasExtendedFormFields
? { ? {
stages: DEFAULT_STAGE_CONFIG, stages: initializeStages(initialPreset),
stageErrors: initializeStageErrors(PRESET_OPTIONS_DEFAULT), stageErrors: stageErrors || initializeStageErrors(initialPreset),
...initialData, ...initialData,
} }
: { stages: [] }; : { stages: [], nameError };
return { return {
selectedPreset: PRESET_OPTIONS[0].value, hiddenStages: [],
selectedPreset: initialPreset,
presetOptions: PRESET_OPTIONS, presetOptions: PRESET_OPTIONS,
name: '', name: '',
nameError: { name: [] }, nameError,
stageErrors: [{}], stageErrors,
...additionalFields, ...additionalFields,
}; };
}, },
computed: { computed: {
...mapState({ ...mapState({
initialFormErrors: 'createValueStreamErrors',
isCreating: 'isCreatingValueStream', isCreating: 'isCreatingValueStream',
}), }),
...mapState('customStages', ['formEvents']), ...mapState('customStages', ['formEvents']),
isValueStreamNameValid() { isValueStreamNameValid() {
return !this.nameError.name?.length; return !this.nameError?.length;
}, },
invalidFeedback() { invalidNameFeedback() {
return this.nameError.name?.join('\n'); return this.nameError?.length ? this.nameError.join('\n\n') : null;
}, },
hasInitialFormErrors() { hasInitialFormErrors() {
const { initialFormErrors } = this; const { initialFormErrors } = this;
return Boolean(Object.keys(initialFormErrors).length); return Boolean(Object.keys(initialFormErrors).length);
}, },
isValid() {
return this.isValueStreamNameValid && !this.hasInitialFormErrors;
},
isLoading() { isLoading() {
return this.isCreating; return this.isCreating;
}, },
primaryProps() { primaryProps() {
return { return {
text: this.$options.I18N.FORM_TITLE, text: this.$options.I18N.FORM_TITLE,
attributes: [ attributes: [{ variant: 'success' }, { loading: this.isLoading }],
{ variant: 'success' },
{ disabled: !this.isValid },
{ loading: this.isLoading },
],
}; };
}, },
secondaryProps() { secondaryProps() {
...@@ -106,41 +124,35 @@ export default { ...@@ -106,41 +124,35 @@ export default {
], ],
}; };
}, },
hiddenStages() { hasFormErrors() {
return this.stages.filter((stage) => stage.hidden); return Boolean(
}, this.nameError.length || this.stageErrors.some((obj) => Object.keys(obj).length),
activeStages() { );
return this.stages.filter((stage) => !stage.hidden);
}, },
}, },
watch: { watch: {
initialFormErrors(newErrors = {}) { initialFormErrors({ name: nameError, stages: stageErrors }) {
this.stageErrors = newErrors; Vue.set(this, 'nameError', nameError);
Vue.set(this, 'stageErrors', stageErrors);
}, },
}, },
mounted() {
const { initialFormErrors } = this;
if (this.hasInitialFormErrors) {
this.stageErrors = initialFormErrors;
}
},
methods: { methods: {
...mapActions(['createValueStream']), ...mapActions(['createValueStream']),
onSubmit() { onSubmit() {
const { name, stages } = this; this.validate();
if (this.hasFormErrors) return false;
return this.createValueStream({ return this.createValueStream({
name, name: this.name,
stages: stages.map(({ name: stageName, ...rest }) => ({ stages: formatStageDataForSubmission(this.stages),
name: stageName,
...rest,
title: stageName,
})),
}).then(() => { }).then(() => {
if (!this.hasInitialFormErrors) { if (!this.hasInitialFormErrors) {
this.$toast.show(sprintf(this.$options.I18N.FORM_CREATED, { name }), { this.$toast.show(sprintf(this.$options.I18N.FORM_CREATED, { name: this.name }), {
position: 'top-center', position: 'top-center',
}); });
this.name = ''; this.name = '';
this.nameError = [];
this.stages = initializeStages(this.selectedPreset);
this.stageErrors = initializeStageErrors(this.selectedPreset);
} }
}); });
}, },
...@@ -156,7 +168,7 @@ export default { ...@@ -156,7 +168,7 @@ export default {
return sprintf(this.$options.I18N.HIDDEN_DEFAULT_STAGE, { name }); return sprintf(this.$options.I18N.HIDDEN_DEFAULT_STAGE, { name });
}, },
validateStages() { validateStages() {
return this.activeStages.map(validateStage); return this.stages.map(validateStage);
}, },
validate() { validate() {
const { name } = this; const { name } = this;
...@@ -175,14 +187,15 @@ export default { ...@@ -175,14 +187,15 @@ export default {
Vue.set(this, 'stages', newStages); Vue.set(this, 'stages', newStages);
}, },
validateStageFields(index) { validateStageFields(index) {
Vue.set(this.stageErrors, index, validateStage(this.activeStages[index])); Vue.set(this.stageErrors, index, validateStage(this.stages[index]));
}, },
fieldErrors(index) { fieldErrors(index) {
return this.stageErrors[index]; return this.stageErrors && this.stageErrors[index] ? this.stageErrors[index] : {};
}, },
onHide(index) { onHide(index) {
const stage = this.stages[index]; const target = this.stages[index];
Vue.set(this.stages, index, { ...stage, hidden: true }); Vue.set(this, 'stages', [...this.stages.filter((_, i) => i !== index)]);
Vue.set(this, 'hiddenStages', [...this.hiddenStages, target]);
}, },
onRemove(index) { onRemove(index) {
const newErrors = this.stageErrors.filter((_, idx) => idx !== index); const newErrors = this.stageErrors.filter((_, idx) => idx !== index);
...@@ -191,9 +204,11 @@ export default { ...@@ -191,9 +204,11 @@ export default {
Vue.set(this, 'stageErrors', [...newErrors]); Vue.set(this, 'stageErrors', [...newErrors]);
}, },
onRestore(hiddenStageIndex) { onRestore(hiddenStageIndex) {
const stage = this.hiddenStages[hiddenStageIndex]; const target = this.hiddenStages[hiddenStageIndex];
const stageIndex = findStageIndexByName(this.stages, stage.name); Vue.set(this, 'hiddenStages', [
Vue.set(this.stages, stageIndex, { ...stage, hidden: false }); ...this.hiddenStages.filter((_, i) => i !== hiddenStageIndex),
]);
Vue.set(this, 'stages', [...this.stages, target]);
}, },
onAddStage() { onAddStage() {
// validate previous stages only and add a new stage // validate previous stages only and add a new stage
...@@ -242,9 +257,10 @@ export default { ...@@ -242,9 +257,10 @@ export default {
> >
<gl-form> <gl-form>
<gl-form-group <gl-form-group
data-testid="create-value-stream-name"
label-for="create-value-stream-name" label-for="create-value-stream-name"
:label="$options.I18N.FORM_FIELD_NAME_LABEL" :label="$options.I18N.FORM_FIELD_NAME_LABEL"
:invalid-feedback="invalidFeedback" :invalid-feedback="invalidNameFeedback"
:state="isValueStreamNameValid" :state="isValueStreamNameValid"
> >
<div class="gl-display-flex gl-justify-content-space-between"> <div class="gl-display-flex gl-justify-content-space-between">
...@@ -275,7 +291,7 @@ export default { ...@@ -275,7 +291,7 @@ export default {
@input="onSelectPreset" @input="onSelectPreset"
/> />
<div v-if="hasExtendedFormFields" data-testid="extended-form-fields"> <div v-if="hasExtendedFormFields" data-testid="extended-form-fields">
<div v-for="(stage, activeStageIndex) in activeStages" :key="stageKey(activeStageIndex)"> <div v-for="(stage, activeStageIndex) in stages" :key="stageKey(activeStageIndex)">
<hr class="gl-my-3" /> <hr class="gl-my-3" />
<span <span
class="gl-display-flex gl-m-0 gl-vertical-align-middle gl-mr-2 gl-font-weight-bold gl-display-flex gl-pb-3" class="gl-display-flex gl-m-0 gl-vertical-align-middle gl-mr-2 gl-font-weight-bold gl-display-flex gl-pb-3"
...@@ -286,7 +302,7 @@ export default { ...@@ -286,7 +302,7 @@ export default {
:stage="stage" :stage="stage"
:stage-events="formEvents" :stage-events="formEvents"
:index="activeStageIndex" :index="activeStageIndex"
:total-stages="activeStages.length" :total-stages="stages.length"
:errors="fieldErrors(activeStageIndex)" :errors="fieldErrors(activeStageIndex)"
@move="handleMove" @move="handleMove"
@remove="onRemove" @remove="onRemove"
...@@ -297,7 +313,7 @@ export default { ...@@ -297,7 +313,7 @@ export default {
:stage="stage" :stage="stage"
:stage-events="formEvents" :stage-events="formEvents"
:index="activeStageIndex" :index="activeStageIndex"
:total-stages="activeStages.length" :total-stages="stages.length"
:errors="fieldErrors(activeStageIndex)" :errors="fieldErrors(activeStageIndex)"
@move="handleMove" @move="handleMove"
@hide="onHide" @hide="onHide"
......
...@@ -47,6 +47,7 @@ export default { ...@@ -47,6 +47,7 @@ export default {
deleteValueStreamError: 'deleteValueStreamError', deleteValueStreamError: 'deleteValueStreamError',
data: 'valueStreams', data: 'valueStreams',
selectedValueStream: 'selectedValueStream', selectedValueStream: 'selectedValueStream',
initialFormErrors: 'createValueStreamErrors',
}), }),
hasValueStreams() { hasValueStreams() {
return Boolean(this.data.length); return Boolean(this.data.length);
...@@ -123,7 +124,10 @@ export default { ...@@ -123,7 +124,10 @@ export default {
<gl-button v-else v-gl-modal-directive="'value-stream-form-modal'">{{ <gl-button v-else v-gl-modal-directive="'value-stream-form-modal'">{{
$options.I18N.CREATE_VALUE_STREAM $options.I18N.CREATE_VALUE_STREAM
}}</gl-button> }}</gl-button>
<value-stream-form :has-extended-form-fields="hasExtendedFormFields" /> <value-stream-form
:initial-form-errors="initialFormErrors"
:has-extended-form-fields="hasExtendedFormFields"
/>
<gl-modal <gl-modal
data-testid="delete-value-stream-modal" data-testid="delete-value-stream-modal"
modal-id="delete-value-stream-modal" modal-id="delete-value-stream-modal"
......
...@@ -41,9 +41,8 @@ export const setDateRange = ({ commit, dispatch }, { skipFetch = false, startDat ...@@ -41,9 +41,8 @@ export const setDateRange = ({ commit, dispatch }, { skipFetch = false, startDat
}; };
export const requestStageData = ({ commit }) => commit(types.REQUEST_STAGE_DATA); export const requestStageData = ({ commit }) => commit(types.REQUEST_STAGE_DATA);
export const receiveStageDataSuccess = ({ commit }, data) => { export const receiveStageDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_STAGE_DATA_SUCCESS, data); commit(types.RECEIVE_STAGE_DATA_SUCCESS, data);
};
export const receiveStageDataError = ({ commit }, error) => { export const receiveStageDataError = ({ commit }, error) => {
const { message = '' } = error; const { message = '' } = error;
...@@ -351,7 +350,7 @@ export const createValueStream = ({ commit, dispatch, getters }, data) => { ...@@ -351,7 +350,7 @@ export const createValueStream = ({ commit, dispatch, getters }, data) => {
.then(({ data: newValueStream }) => dispatch('receiveCreateValueStreamSuccess', newValueStream)) .then(({ data: newValueStream }) => dispatch('receiveCreateValueStreamSuccess', newValueStream))
.catch(({ response } = {}) => { .catch(({ response } = {}) => {
const { data: { message, payload: { errors } } = null } = response; const { data: { message, payload: { errors } } = null } = response;
commit(types.RECEIVE_CREATE_VALUE_STREAM_ERROR, { message, errors }); commit(types.RECEIVE_CREATE_VALUE_STREAM_ERROR, { message, errors, data });
}); });
}; };
......
...@@ -66,6 +66,8 @@ export default { ...@@ -66,6 +66,8 @@ export default {
}, },
[types.RECEIVE_CREATE_STAGE_ERROR](state) { [types.RECEIVE_CREATE_STAGE_ERROR](state) {
state.isSavingCustomStage = false; state.isSavingCustomStage = false;
state.isCreatingCustomStage = false;
state.isEditingCustomStage = false;
}, },
[types.RECEIVE_CREATE_STAGE_SUCCESS](state) { [types.RECEIVE_CREATE_STAGE_SUCCESS](state) {
state.formErrors = null; state.formErrors = null;
......
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { transformRawStages } from '../utils';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { transformRawStages, prepareStageErrors } from '../utils';
export default { export default {
[types.SET_FEATURE_FLAGS](state, featureFlags) { [types.SET_FEATURE_FLAGS](state, featureFlags) {
...@@ -67,7 +67,8 @@ export default { ...@@ -67,7 +67,8 @@ export default {
state.stages = []; state.stages = [];
}, },
[types.RECEIVE_GROUP_STAGES_SUCCESS](state, stages) { [types.RECEIVE_GROUP_STAGES_SUCCESS](state, stages) {
state.stages = transformRawStages(stages); const transformedStages = transformRawStages(stages);
state.stages = transformedStages.sort((a, b) => a?.id > b?.id);
}, },
[types.REQUEST_UPDATE_STAGE](state) { [types.REQUEST_UPDATE_STAGE](state) {
state.isLoading = true; state.isLoading = true;
...@@ -120,9 +121,10 @@ export default { ...@@ -120,9 +121,10 @@ export default {
state.isCreatingValueStream = true; state.isCreatingValueStream = true;
state.createValueStreamErrors = {}; state.createValueStreamErrors = {};
}, },
[types.RECEIVE_CREATE_VALUE_STREAM_ERROR](state, { errors } = {}) { [types.RECEIVE_CREATE_VALUE_STREAM_ERROR](state, { data: { stages = [] }, errors = {} }) {
const { stages: stageErrors = {}, ...rest } = errors;
state.createValueStreamErrors = { ...rest, stages: prepareStageErrors(stages, stageErrors) };
state.isCreatingValueStream = false; state.isCreatingValueStream = false;
state.createValueStreamErrors = errors;
}, },
[types.RECEIVE_CREATE_VALUE_STREAM_SUCCESS](state, valueStream) { [types.RECEIVE_CREATE_VALUE_STREAM_SUCCESS](state, valueStream) {
state.isCreatingValueStream = false; state.isCreatingValueStream = false;
......
...@@ -105,6 +105,19 @@ export const transformRawTasksByTypeData = (data = []) => { ...@@ -105,6 +105,19 @@ export const transformRawTasksByTypeData = (data = []) => {
return data.map((d) => convertObjectPropsToCamelCase(d, { deep: true })); return data.map((d) => convertObjectPropsToCamelCase(d, { deep: true }));
}; };
/**
* Prepares the stage errors for use in the create value stream form
*
* The JSON error response returns a key value pair, the key corresponds to the
* index of the stage with errors and the value is the returned error(s)
*
* @param {Array} stages - Array of value stream stages
* @param {Object} errors - Key value pair of stage errors
* @returns {Array} Returns and array of stage error objects
*/
export const prepareStageErrors = (stages, errors) =>
stages.length ? stages.map((_, index) => convertObjectPropsToCamelCase(errors[index]) || {}) : [];
/** /**
* Takes the duration data for selected stages, transforms the date values and returns * Takes the duration data for selected stages, transforms the date values and returns
* the data in a flattened array * the data in a flattened array
......
...@@ -111,7 +111,7 @@ describe('validateStage', () => { ...@@ -111,7 +111,7 @@ describe('validateStage', () => {
describe('validateValueStreamName,', () => { describe('validateValueStreamName,', () => {
it('with valid data returns an empty array', () => { it('with valid data returns an empty array', () => {
expect(validateValueStreamName({ name: 'Cool stream name' })).toEqual({ name: [] }); expect(validateValueStreamName({ name: 'Cool stream name' })).toEqual([]);
}); });
it.each` it.each`
...@@ -120,6 +120,6 @@ describe('validateValueStreamName,', () => { ...@@ -120,6 +120,6 @@ describe('validateValueStreamName,', () => {
${''} | ${ERRORS.VALUE_STREAM_NAME_MIN_LENGTH} | ${'too short'} ${''} | ${ERRORS.VALUE_STREAM_NAME_MIN_LENGTH} | ${'too short'}
`('returns "$error" if name is $msg', ({ name, error }) => { `('returns "$error" if name is $msg', ({ name, error }) => {
const result = validateValueStreamName({ name }); const result = validateValueStreamName({ name });
expectFieldError({ result, error, field: 'name' }); expect(result).toEqual([error]);
}); });
}); });
...@@ -133,7 +133,7 @@ describe('CustomStageForm', () => { ...@@ -133,7 +133,7 @@ describe('CustomStageForm', () => {
it('clears the error when the field changes', async () => { it('clears the error when the field changes', async () => {
await setNameField('not an issue'); await setNameField('not an issue');
expect(findFieldErrors('name')).not.toContain('Stage name already exists'); expect(findFieldErrors('name')).toBeUndefined();
}); });
}); });
}); });
......
...@@ -2,6 +2,9 @@ import { GlModal } from '@gitlab/ui'; ...@@ -2,6 +2,9 @@ import { GlModal } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import ValueStreamForm from 'ee/analytics/cycle_analytics/components/value_stream_form.vue'; import ValueStreamForm from 'ee/analytics/cycle_analytics/components/value_stream_form.vue';
import DefaultStageFields from 'ee/analytics/cycle_analytics/components/create_value_stream_form/default_stage_fields.vue';
import CustomStageFields from 'ee/analytics/cycle_analytics/components/create_value_stream_form/custom_stage_fields.vue';
import { PRESET_OPTIONS_BLANK } from 'ee/analytics/cycle_analytics/components/create_value_stream_form/constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { customStageEvents as formEvents } from '../mock_data'; import { customStageEvents as formEvents } from '../mock_data';
...@@ -15,13 +18,20 @@ describe('ValueStreamForm', () => { ...@@ -15,13 +18,20 @@ describe('ValueStreamForm', () => {
const mockEvent = { preventDefault: jest.fn() }; const mockEvent = { preventDefault: jest.fn() };
const mockToastShow = jest.fn(); const mockToastShow = jest.fn();
const streamName = 'Cool stream'; const streamName = 'Cool stream';
const createValueStreamErrors = { name: ['Name field required'] }; const initialFormErrors = { name: ['Name field required'] };
const initialFormStageErrors = {
stages: [
{
name: ['Name field is required'],
endEventIdentifier: ['Please select a start event first'],
},
],
};
const fakeStore = ({ initialState = {} }) => const fakeStore = ({ initialState = {} }) =>
new Vuex.Store({ new Vuex.Store({
state: { state: {
isCreatingValueStream: false, isCreatingValueStream: false,
createValueStreamErrors: {},
...initialState, ...initialState,
}, },
actions: { actions: {
...@@ -37,7 +47,7 @@ describe('ValueStreamForm', () => { ...@@ -37,7 +47,7 @@ describe('ValueStreamForm', () => {
}, },
}); });
const createComponent = ({ props = {}, data = {}, initialState = {} } = {}) => const createComponent = ({ props = {}, data = {}, initialState = {}, stubs = {} } = {}) =>
extendedWrapper( extendedWrapper(
shallowMount(ValueStreamForm, { shallowMount(ValueStreamForm, {
localVue, localVue,
...@@ -55,6 +65,9 @@ describe('ValueStreamForm', () => { ...@@ -55,6 +65,9 @@ describe('ValueStreamForm', () => {
show: mockToastShow, show: mockToastShow,
}, },
}, },
stubs: {
...stubs,
},
}), }),
); );
...@@ -66,6 +79,8 @@ describe('ValueStreamForm', () => { ...@@ -66,6 +79,8 @@ describe('ValueStreamForm', () => {
const findBtn = (btn) => findModal().props(btn); const findBtn = (btn) => findModal().props(btn);
const findSubmitDisabledAttribute = (attribute) => const findSubmitDisabledAttribute = (attribute) =>
findBtn('actionPrimary').attributes[1][attribute]; findBtn('actionPrimary').attributes[1][attribute];
const expectFieldError = (testId, error = '') =>
expect(wrapper.findByTestId(testId).attributes('invalid-feedback')).toBe(error);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -78,7 +93,7 @@ describe('ValueStreamForm', () => { ...@@ -78,7 +93,7 @@ describe('ValueStreamForm', () => {
}); });
it('submit button is enabled', () => { it('submit button is enabled', () => {
expect(findSubmitDisabledAttribute('disabled')).toBe(false); expect(findSubmitDisabledAttribute('disabled')).toBeUndefined();
}); });
it('does not include extended fields', () => { it('does not include extended fields', () => {
...@@ -123,21 +138,67 @@ describe('ValueStreamForm', () => { ...@@ -123,21 +138,67 @@ describe('ValueStreamForm', () => {
expect(wrapper.vm.stages.length).toBe(7); expect(wrapper.vm.stages.length).toBe(7);
}); });
it('validates existing fields when clicked', () => {
expect(wrapper.vm.nameError).toEqual([]);
clickAddStage();
expect(wrapper.vm.nameError).toEqual(['Name is required']);
});
});
describe('form errors', () => {
const commonExtendedData = {
props: {
hasExtendedFormFields: true,
initialFormErrors: initialFormStageErrors,
},
};
it('renders errors for a default stage field', () => {
wrapper = createComponent({
...commonExtendedData,
stubs: {
DefaultStageFields,
},
});
expectFieldError('default-stage-name-0', initialFormStageErrors.stages[0].name[0]);
});
it('renders errors for a custom stage field', async () => {
wrapper = createComponent({
props: {
...commonExtendedData.props,
initialPreset: PRESET_OPTIONS_BLANK,
},
stubs: {
CustomStageFields,
},
});
expectFieldError('custom-stage-name-0', initialFormStageErrors.stages[0].name[0]);
expectFieldError(
'custom-stage-end-event-0',
initialFormStageErrors.stages[0].endEventIdentifier[0],
);
});
}); });
}); });
describe('form errors', () => { describe('form errors', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ wrapper = createComponent({
data: { name: streamName }, data: { name: '' },
initialState: { props: {
createValueStreamErrors, initialFormErrors,
}, },
}); });
}); });
it('submit button is disabled', () => { it('renders errors for the name field', () => {
expect(findSubmitDisabledAttribute('disabled')).toBe(true); expectFieldError('create-value-stream-name', initialFormErrors.name[0]);
}); });
}); });
...@@ -146,10 +207,6 @@ describe('ValueStreamForm', () => { ...@@ -146,10 +207,6 @@ describe('ValueStreamForm', () => {
wrapper = createComponent({ data: { name: streamName } }); wrapper = createComponent({ data: { name: streamName } });
}); });
it('submit button is enabled', () => {
expect(findSubmitDisabledAttribute('disabled')).toBe(false);
});
describe('form submitted successfully', () => { describe('form submitted successfully', () => {
beforeEach(() => { beforeEach(() => {
clickSubmit(); clickSubmit();
...@@ -177,8 +234,8 @@ describe('ValueStreamForm', () => { ...@@ -177,8 +234,8 @@ describe('ValueStreamForm', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ wrapper = createComponent({
data: { name: streamName }, data: { name: streamName },
initialState: { props: {
createValueStreamErrors, initialFormErrors,
}, },
}); });
......
...@@ -948,7 +948,7 @@ describe('Value Stream Analytics actions', () => { ...@@ -948,7 +948,7 @@ describe('Value Stream Analytics actions', () => {
{ type: types.REQUEST_CREATE_VALUE_STREAM }, { type: types.REQUEST_CREATE_VALUE_STREAM },
{ {
type: types.RECEIVE_CREATE_VALUE_STREAM_ERROR, type: types.RECEIVE_CREATE_VALUE_STREAM_ERROR,
payload: { message, errors }, payload: { message, data: payload, errors },
}, },
], ],
[], [],
......
...@@ -15,6 +15,7 @@ import { ...@@ -15,6 +15,7 @@ import {
} from '../mock_data'; } from '../mock_data';
let state = null; let state = null;
const { stages } = customizableStagesAndEvents;
describe('Value Stream Analytics mutations', () => { describe('Value Stream Analytics mutations', () => {
beforeEach(() => { beforeEach(() => {
...@@ -61,16 +62,16 @@ describe('Value Stream Analytics mutations', () => { ...@@ -61,16 +62,16 @@ describe('Value Stream Analytics mutations', () => {
}); });
it.each` it.each`
mutation | payload | expectedState mutation | payload | expectedState
${types.SET_FEATURE_FLAGS} | ${{ hasDurationChart: true }} | ${{ featureFlags: { hasDurationChart: true } }} ${types.SET_FEATURE_FLAGS} | ${{ hasDurationChart: true }} | ${{ featureFlags: { hasDurationChart: true } }}
${types.SET_SELECTED_PROJECTS} | ${selectedProjects} | ${{ selectedProjects }} ${types.SET_SELECTED_PROJECTS} | ${selectedProjects} | ${{ selectedProjects }}
${types.SET_DATE_RANGE} | ${{ startDate, endDate }} | ${{ startDate, endDate }} ${types.SET_DATE_RANGE} | ${{ startDate, endDate }} | ${{ startDate, endDate }}
${types.SET_SELECTED_STAGE} | ${{ id: 'first-stage' }} | ${{ selectedStage: { id: 'first-stage' } }} ${types.SET_SELECTED_STAGE} | ${{ id: 'first-stage' }} | ${{ selectedStage: { id: 'first-stage' } }}
${types.RECEIVE_CREATE_VALUE_STREAM_ERROR} | ${{ errors: { name: ['is required'] } }} | ${{ createValueStreamErrors: { name: ['is required'] }, isCreatingValueStream: false }} ${types.RECEIVE_CREATE_VALUE_STREAM_ERROR} | ${{ data: { stages }, errors: { name: ['is required'] } }} | ${{ createValueStreamErrors: { name: ['is required'] }, isCreatingValueStream: false }}
${types.RECEIVE_DELETE_VALUE_STREAM_ERROR} | ${'Some error occurred'} | ${{ deleteValueStreamError: 'Some error occurred' }} ${types.RECEIVE_DELETE_VALUE_STREAM_ERROR} | ${'Some error occurred'} | ${{ deleteValueStreamError: 'Some error occurred' }}
${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${valueStreams} | ${{ valueStreams, isLoadingValueStreams: false }} ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${valueStreams} | ${{ valueStreams, isLoadingValueStreams: false }}
${types.SET_SELECTED_VALUE_STREAM} | ${valueStreams[1].id} | ${{ selectedValueStream: {} }} ${types.SET_SELECTED_VALUE_STREAM} | ${valueStreams[1].id} | ${{ selectedValueStream: {} }}
${types.RECEIVE_CREATE_VALUE_STREAM_SUCCESS} | ${valueStreams[1]} | ${{ selectedValueStream: valueStreams[1] }} ${types.RECEIVE_CREATE_VALUE_STREAM_SUCCESS} | ${valueStreams[1]} | ${{ selectedValueStream: valueStreams[1] }}
`( `(
'$mutation with payload $payload will update state with $expectedState', '$mutation with payload $payload will update state with $expectedState',
({ mutation, payload, expectedState }) => { ({ mutation, payload, expectedState }) => {
...@@ -126,7 +127,7 @@ describe('Value Stream Analytics mutations', () => { ...@@ -126,7 +127,7 @@ describe('Value Stream Analytics mutations', () => {
describe(`${types.RECEIVE_GROUP_STAGES_SUCCESS}`, () => { describe(`${types.RECEIVE_GROUP_STAGES_SUCCESS}`, () => {
describe('with data', () => { describe('with data', () => {
beforeEach(() => { beforeEach(() => {
mutations[types.RECEIVE_GROUP_STAGES_SUCCESS](state, customizableStagesAndEvents.stages); mutations[types.RECEIVE_GROUP_STAGES_SUCCESS](state, stages);
}); });
it('will convert the stats object to stages', () => { it('will convert the stats object to stages', () => {
...@@ -180,14 +181,11 @@ describe('Value Stream Analytics mutations', () => { ...@@ -180,14 +181,11 @@ describe('Value Stream Analytics mutations', () => {
${'selectedProjects'} | ${initialData.selectedProjects} ${'selectedProjects'} | ${initialData.selectedProjects}
${'startDate'} | ${initialData.createdAfter} ${'startDate'} | ${initialData.createdAfter}
${'endDate'} | ${initialData.createdBefore} ${'endDate'} | ${initialData.createdBefore}
`( `('$stateKey will be set to $expectedState', ({ stateKey, expectedState }) => {
'$mutation with payload $payload will update state with $expectedState', state = {};
({ stateKey, expectedState }) => { mutations[types.INITIALIZE_CYCLE_ANALYTICS](state, initialData);
state = {};
mutations[types.INITIALIZE_CYCLE_ANALYTICS](state, initialData);
expect(state[stateKey]).toEqual(expectedState); expect(state[stateKey]).toEqual(expectedState);
}, });
);
}); });
}); });
...@@ -16,6 +16,7 @@ import { ...@@ -16,6 +16,7 @@ import {
toggleSelectedLabel, toggleSelectedLabel,
transformStagesForPathNavigation, transformStagesForPathNavigation,
prepareTimeMetricsData, prepareTimeMetricsData,
prepareStageErrors,
} from 'ee/analytics/cycle_analytics/utils'; } from 'ee/analytics/cycle_analytics/utils';
import { toYmd } from 'ee/analytics/shared/utils'; import { toYmd } from 'ee/analytics/shared/utils';
import { getDatesInRange } from '~/lib/utils/datetime_utility'; import { getDatesInRange } from '~/lib/utils/datetime_utility';
...@@ -176,6 +177,29 @@ describe('Value Stream Analytics utils', () => { ...@@ -176,6 +177,29 @@ describe('Value Stream Analytics utils', () => {
}); });
}); });
describe('prepareStageErrors', () => {
const stages = [{ name: 'stage 1' }, { name: 'stage 2' }, { name: 'stage 3' }];
const nameError = { name: "Can't be blank" };
const stageErrors = { 1: nameError };
it('returns an object for each stage', () => {
const res = prepareStageErrors(stages, stageErrors);
expect(res[0]).toEqual({});
expect(res[1]).toEqual(nameError);
expect(res[2]).toEqual({});
});
it('returns the same number of error objects as stages', () => {
const res = prepareStageErrors(stages, stageErrors);
expect(res.length).toEqual(stages.length);
});
it('returns an empty object for each stage if there are no errors', () => {
const res = prepareStageErrors(stages, {});
expect(res).toEqual([{}, {}, {}]);
});
});
describe('isPersistedStage', () => { describe('isPersistedStage', () => {
it.each` it.each`
custom | id | expected custom | id | expected
......
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