Commit 0da898bf authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch '267537-edit-custom-value-streams' into 'master'

[FE] VSA - UI add edit controls to path navigation

See merge request gitlab-org/gitlab!53135
parents 6ea26ab1 6f1ec0d1
...@@ -2,9 +2,12 @@ import { __, s__, sprintf } from '~/locale'; ...@@ -2,9 +2,12 @@ import { __, s__, sprintf } from '~/locale';
export const NAME_MAX_LENGTH = 100; export const NAME_MAX_LENGTH = 100;
export const I18N = { export const i18n = {
FORM_TITLE: __('Create Value Stream'), FORM_TITLE: s__('CreateValueStreamForm|Create Value Stream'),
EDIT_FORM_TITLE: s__('CreateValueStreamForm|Edit Value Stream'),
EDIT_FORM_ACTION: s__('CreateValueStreamForm|Save Value Stream'),
FORM_CREATED: s__("CreateValueStreamForm|'%{name}' Value Stream created"), FORM_CREATED: s__("CreateValueStreamForm|'%{name}' Value Stream created"),
FORM_EDITED: s__("CreateValueStreamForm|'%{name}' Value Stream edited"),
RECOVER_HIDDEN_STAGE: s__('CreateValueStreamForm|Recover hidden stage'), RECOVER_HIDDEN_STAGE: s__('CreateValueStreamForm|Recover hidden stage'),
RESTORE_HIDDEN_STAGE: s__('CreateValueStreamForm|Restore stage'), RESTORE_HIDDEN_STAGE: s__('CreateValueStreamForm|Restore stage'),
RESTORE_DEFAULTS: s__('CreateValueStreamForm|Restore defaults'), RESTORE_DEFAULTS: s__('CreateValueStreamForm|Restore defaults'),
...@@ -55,23 +58,17 @@ export const STAGE_SORT_DIRECTION = { ...@@ -55,23 +58,17 @@ export const STAGE_SORT_DIRECTION = {
DOWN: 'DOWN', DOWN: 'DOWN',
}; };
export const defaultErrors = { export const formFieldKeys = [
id: [], 'id',
name: [], 'name',
startEventIdentifier: [], 'startEventIdentifier',
startEventLabelId: [], 'endEventIdentifier',
endEventIdentifier: [], 'startEventLabelId',
endEventLabelId: [], 'endEventLabelId',
}; ];
export const defaultFields = { export const defaultFields = formFieldKeys.reduce((acc, field) => ({ ...acc, [field]: null }), {});
id: null, export const defaultErrors = formFieldKeys.reduce((acc, field) => ({ ...acc, [field]: [] }), {});
name: null,
startEventIdentifier: null,
startEventLabelId: null,
endEventIdentifier: null,
endEventLabelId: null,
};
export const defaultCustomStageFields = { ...defaultFields, custom: true }; export const defaultCustomStageFields = { ...defaultFields, custom: true };
...@@ -79,11 +76,11 @@ export const PRESET_OPTIONS_DEFAULT = 'default'; ...@@ -79,11 +76,11 @@ export const PRESET_OPTIONS_DEFAULT = 'default';
export const PRESET_OPTIONS_BLANK = 'blank'; export const PRESET_OPTIONS_BLANK = 'blank';
export const PRESET_OPTIONS = [ export const PRESET_OPTIONS = [
{ {
text: I18N.TEMPLATE_DEFAULT, text: i18n.TEMPLATE_DEFAULT,
value: PRESET_OPTIONS_DEFAULT, value: PRESET_OPTIONS_DEFAULT,
}, },
{ {
text: I18N.TEMPLATE_BLANK, text: i18n.TEMPLATE_BLANK,
value: PRESET_OPTIONS_BLANK, value: PRESET_OPTIONS_BLANK,
}, },
]; ];
...@@ -95,14 +92,14 @@ export const PRESET_OPTIONS = [ ...@@ -95,14 +92,14 @@ export const PRESET_OPTIONS = [
export const ADDITIONAL_DEFAULT_STAGE_EVENTS = [ export const ADDITIONAL_DEFAULT_STAGE_EVENTS = [
{ {
identifier: 'issue_stage_end', identifier: 'issue_stage_end',
name: I18N.ISSUE_STAGE_END, name: i18n.ISSUE_STAGE_END,
}, },
{ {
identifier: 'plan_stage_start', identifier: 'plan_stage_start',
name: I18N.PLAN_STAGE_START, name: i18n.PLAN_STAGE_START,
}, },
{ {
identifier: 'code_stage_start', identifier: 'code_stage_start',
name: I18N.CODE_STAGE_START, name: i18n.CODE_STAGE_START,
}, },
]; ];
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import { GlFormGroup, GlFormInput, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { GlFormGroup, GlFormInput, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { isLabelEvent, getLabelEventsIdentifiers } from '../../utils'; import { isLabelEvent, getLabelEventsIdentifiers } from '../../utils';
import LabelsSelector from '../labels_selector.vue'; import LabelsSelector from '../labels_selector.vue';
import { I18N } from './constants'; import { i18n } from './constants';
import StageFieldActions from './stage_field_actions.vue'; import StageFieldActions from './stage_field_actions.vue';
import { startEventOptions, endEventOptions } from './utils'; import { startEventOptions, endEventOptions } from './utils';
...@@ -82,10 +82,10 @@ export default { ...@@ -82,10 +82,10 @@ export default {
return ev?.name || null; return ev?.name || null;
}, },
eventName(eventId, textKey) { eventName(eventId, textKey) {
return eventId ? this.eventNameByIdentifier(eventId) : this.$options.I18N[textKey]; return eventId ? this.eventNameByIdentifier(eventId) : this.$options.i18n[textKey];
}, },
}, },
I18N, i18n,
}; };
</script> </script>
<template> <template>
...@@ -101,7 +101,7 @@ export default { ...@@ -101,7 +101,7 @@ export default {
<gl-form-input <gl-form-input
v-model.trim="stage.name" v-model.trim="stage.name"
:name="`custom-stage-name-${index}`" :name="`custom-stage-name-${index}`"
:placeholder="$options.I18N.FORM_FIELD_STAGE_NAME_PLACEHOLDER" :placeholder="$options.i18n.FORM_FIELD_STAGE_NAME_PLACEHOLDER"
required required
@input="$emit('input', { field: 'name', value: $event })" @input="$emit('input', { field: 'name', value: $event })"
/> />
...@@ -120,7 +120,7 @@ export default { ...@@ -120,7 +120,7 @@ export default {
<gl-form-group <gl-form-group
:data-testid="`custom-stage-start-event-${index}`" :data-testid="`custom-stage-start-event-${index}`"
class="gl-w-half gl-mr-2" class="gl-w-half gl-mr-2"
:label="$options.I18N.FORM_FIELD_START_EVENT" :label="$options.i18n.FORM_FIELD_START_EVENT"
:state="hasFieldErrors('startEventIdentifier')" :state="hasFieldErrors('startEventIdentifier')"
:invalid-feedback="fieldErrorMessage('startEventIdentifier')" :invalid-feedback="fieldErrorMessage('startEventIdentifier')"
> >
...@@ -144,7 +144,7 @@ export default { ...@@ -144,7 +144,7 @@ export default {
v-if="startEventRequiresLabel" v-if="startEventRequiresLabel"
class="gl-w-half gl-ml-2" class="gl-w-half gl-ml-2"
:data-testid="`custom-stage-start-event-label-${index}`" :data-testid="`custom-stage-start-event-label-${index}`"
:label="$options.I18N.FORM_FIELD_START_EVENT_LABEL" :label="$options.i18n.FORM_FIELD_START_EVENT_LABEL"
:state="hasFieldErrors('startEventLabelId')" :state="hasFieldErrors('startEventLabelId')"
:invalid-feedback="fieldErrorMessage('startEventLabelId')" :invalid-feedback="fieldErrorMessage('startEventLabelId')"
> >
...@@ -159,7 +159,7 @@ export default { ...@@ -159,7 +159,7 @@ export default {
<gl-form-group <gl-form-group
:data-testid="`custom-stage-end-event-${index}`" :data-testid="`custom-stage-end-event-${index}`"
class="gl-w-half gl-mr-2" class="gl-w-half gl-mr-2"
:label="$options.I18N.FORM_FIELD_END_EVENT" :label="$options.i18n.FORM_FIELD_END_EVENT"
:state="hasFieldErrors('endEventIdentifier')" :state="hasFieldErrors('endEventIdentifier')"
:invalid-feedback="fieldErrorMessage('endEventIdentifier')" :invalid-feedback="fieldErrorMessage('endEventIdentifier')"
> >
...@@ -184,7 +184,7 @@ export default { ...@@ -184,7 +184,7 @@ export default {
v-if="endEventRequiresLabel" v-if="endEventRequiresLabel"
class="gl-w-half gl-ml-2" class="gl-w-half gl-ml-2"
:data-testid="`custom-stage-end-event-label-${index}`" :data-testid="`custom-stage-end-event-label-${index}`"
:label="$options.I18N.FORM_FIELD_END_EVENT_LABEL" :label="$options.i18n.FORM_FIELD_END_EVENT_LABEL"
:state="hasFieldErrors('endEventLabelId')" :state="hasFieldErrors('endEventLabelId')"
:invalid-feedback="fieldErrorMessage('endEventLabelId')" :invalid-feedback="fieldErrorMessage('endEventLabelId')"
> >
......
<script> <script>
import { GlFormGroup, GlFormInput, GlFormText } from '@gitlab/ui'; import { GlFormGroup, GlFormInput, GlFormText } from '@gitlab/ui';
import { I18N, ADDITIONAL_DEFAULT_STAGE_EVENTS } from './constants'; import { i18n, ADDITIONAL_DEFAULT_STAGE_EVENTS } from './constants';
import StageFieldActions from './stage_field_actions.vue'; import StageFieldActions from './stage_field_actions.vue';
const findStageEvent = (stageEvents = [], eid = null) => { const findStageEvent = (stageEvents = [], eid = null) => {
...@@ -55,7 +55,7 @@ export default { ...@@ -55,7 +55,7 @@ export default {
return eventIdToName([...this.stageEvents, ...ADDITIONAL_DEFAULT_STAGE_EVENTS], eventId); return eventIdToName([...this.stageEvents, ...ADDITIONAL_DEFAULT_STAGE_EVENTS], eventId);
}, },
}, },
I18N, i18n,
}; };
</script> </script>
<template> <template>
...@@ -71,7 +71,7 @@ export default { ...@@ -71,7 +71,7 @@ export default {
<gl-form-input <gl-form-input
v-model.trim="stage.name" v-model.trim="stage.name"
:name="`create-value-stream-stage-${index}`" :name="`create-value-stream-stage-${index}`"
:placeholder="$options.I18N.FORM_FIELD_STAGE_NAME_PLACEHOLDER" :placeholder="$options.i18n.FORM_FIELD_STAGE_NAME_PLACEHOLDER"
required required
@input="$emit('input', $event)" @input="$emit('input', $event)"
/> />
...@@ -86,7 +86,7 @@ export default { ...@@ -86,7 +86,7 @@ export default {
</div> </div>
<div class="gl-display-flex gl-align-items-center" :data-testid="`stage-start-event-${index}`"> <div class="gl-display-flex gl-align-items-center" :data-testid="`stage-start-event-${index}`">
<span class="gl-m-0 gl-vertical-align-middle gl-mr-2 gl-font-weight-bold">{{ <span class="gl-m-0 gl-vertical-align-middle gl-mr-2 gl-font-weight-bold">{{
$options.I18N.DEFAULT_FIELD_START_EVENT_LABEL $options.i18n.DEFAULT_FIELD_START_EVENT_LABEL
}}</span> }}</span>
<gl-form-text class="gl-m-0">{{ eventName(stage.startEventIdentifier) }}</gl-form-text> <gl-form-text class="gl-m-0">{{ eventName(stage.startEventIdentifier) }}</gl-form-text>
<gl-form-text v-if="stage.startEventLabel" class="gl-m-0" <gl-form-text v-if="stage.startEventLabel" class="gl-m-0"
...@@ -95,7 +95,7 @@ export default { ...@@ -95,7 +95,7 @@ export default {
</div> </div>
<div class="gl-display-flex gl-align-items-center" :data-testid="`stage-end-event-${index}`"> <div class="gl-display-flex gl-align-items-center" :data-testid="`stage-end-event-${index}`">
<span class="gl-m-0 gl-vertical-align-middle gl-mr-2 gl-font-weight-bold">{{ <span class="gl-m-0 gl-vertical-align-middle gl-mr-2 gl-font-weight-bold">{{
$options.I18N.DEFAULT_FIELD_END_EVENT_LABEL $options.i18n.DEFAULT_FIELD_END_EVENT_LABEL
}}</span> }}</span>
<gl-form-text class="gl-m-0">{{ eventName(stage.endEventIdentifier) }}</gl-form-text> <gl-form-text class="gl-m-0">{{ eventName(stage.endEventIdentifier) }}</gl-form-text>
<gl-form-text v-if="stage.endEventLabel" class="gl-m-0" <gl-form-text v-if="stage.endEventLabel" class="gl-m-0"
......
import { isEqual, pick } from 'lodash';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import { DEFAULT_STAGE_NAMES } from '../../constants'; import { DEFAULT_STAGE_NAMES } from '../../constants';
import { isStartEvent, getAllowedEndEvents, eventToOption, eventsByIdentifier } from '../../utils'; import { isStartEvent, getAllowedEndEvents, eventToOption, eventsByIdentifier } from '../../utils';
import { I18N, ERRORS, defaultErrors, defaultFields, NAME_MAX_LENGTH } from './constants'; import {
i18n,
ERRORS,
defaultErrors,
defaultFields,
NAME_MAX_LENGTH,
formFieldKeys,
} from './constants';
/** /**
* @typedef {Object} CustomStageEvents * @typedef {Object} CustomStageEvents
...@@ -22,7 +31,7 @@ import { I18N, ERRORS, defaultErrors, defaultFields, NAME_MAX_LENGTH } from './c ...@@ -22,7 +31,7 @@ import { I18N, ERRORS, defaultErrors, defaultFields, NAME_MAX_LENGTH } from './c
* @returns {DropdownData[]} array of start events formatted for dropdowns * @returns {DropdownData[]} array of start events formatted for dropdowns
*/ */
export const startEventOptions = (eventsList) => [ export const startEventOptions = (eventsList) => [
{ value: null, text: I18N.SELECT_START_EVENT }, { value: null, text: i18n.SELECT_START_EVENT },
...eventsList.filter(isStartEvent).map(eventToOption), ...eventsList.filter(isStartEvent).map(eventToOption),
]; ];
...@@ -37,7 +46,7 @@ export const startEventOptions = (eventsList) => [ ...@@ -37,7 +46,7 @@ export const startEventOptions = (eventsList) => [
export const endEventOptions = (eventsList, startEventIdentifier) => { export const endEventOptions = (eventsList, startEventIdentifier) => {
const endEvents = getAllowedEndEvents(eventsList, startEventIdentifier); const endEvents = getAllowedEndEvents(eventsList, startEventIdentifier);
return [ return [
{ value: null, text: I18N.SELECT_END_EVENT }, { value: null, text: i18n.SELECT_END_EVENT },
...eventsByIdentifier(eventsList, endEvents).map(eventToOption), ...eventsByIdentifier(eventsList, endEvents).map(eventToOption),
]; ];
}; };
...@@ -123,3 +132,111 @@ export const validateValueStreamName = ({ name = '' }) => { ...@@ -123,3 +132,111 @@ export const validateValueStreamName = ({ name = '' }) => {
} }
return errors; return errors;
}; };
/**
* Formats the value stream stages for submission, ensures that the
* 'custom' property is set when we are editing, we include the `id` if its
* set and all fields are converted to snake case
*
* @param {Array} stages array of value stream stages
* @param {Boolean} isEditing flag to indicate if we are editing a value stream or creating
* @returns {Array} the array prepared to be submitted for persistence
*/
export const formatStageDataForSubmission = (stages, isEditing = false) => {
return stages.map(({ id = null, custom = false, name, ...rest }) => {
let editProps = { custom };
if (isEditing) {
// We can add a new stage to the value stream when either creating, or editing
// If a new stage has been added then at this point, the `id` won't exist
// The new stage is still `custom` but wont have an id until the form submits and its persisted to the DB
editProps = id ? { id, custom: true } : { custom: true };
}
return custom
? convertObjectPropsToSnakeCase({ ...rest, ...editProps, name })
: convertObjectPropsToSnakeCase({ ...editProps, name });
});
};
/**
* Checks an array of value stream stages to see if there are
* any differences in the values they contain
*
* @param {Array} stages array of value stream stages
* @param {Array} stages array of value stream stages
* @returns {Boolean} returns true if there is a difference in the 2 arrays
*/
export const hasDirtyStage = (currentStages, originalStages) => {
const cs = currentStages.map((s) => pick(s, formFieldKeys));
const os = originalStages.map((s) => pick(s, formFieldKeys));
return !isEqual(cs, os);
};
/**
* Checks if the target name matches the name of any of the value
* stream stages passed in
*
* @param {Array} stages array of value stream stages
* @param {String} targetName name we are searching for
* @returns {Object} returns the found object or null
*/
const findStageByName = (stages, targetName = '') =>
stages.find(({ name }) => name.toLowerCase().trim() === targetName.toLowerCase().trim());
/**
* Returns a valid custom value stream stage
*
* @param {Object} stage a raw value stream stage retrieved from the vuex store
* @returns {Object} the same stage with fields adjusted for the value stream form
*/
const prepareCustomStage = ({ startEventLabel = {}, endEventLabel = {}, ...rest }) => ({
...rest,
startEventLabelId: startEventLabel?.id || null,
endEventLabelId: endEventLabel?.id || null,
isDefault: false,
});
/**
* Returns a valid default value stream stage
*
* @param {Object} stage a raw value stream stage retrieved from the vuex store
* @returns {Object} the same stage with fields adjusted for the value stream form
*/
const prepareDefaultStage = (defaultStageConfig, { name, ...rest }) => {
// default stages currently dont have any label based events
const stage = findStageByName(defaultStageConfig, name) || null;
if (!stage) return {};
const { startEventIdentifier = null, endEventIdentifier = null } = stage;
return {
...rest,
name,
startEventIdentifier,
endEventIdentifier,
isDefault: true,
};
};
/**
* Returns a valid array of value stream stages for
* use in the value stream form
*
* @param {Array} stage an array of raw value stream stages retrieved from the vuex store
* @param {Array} stage an array of raw value stream stages retrieved from the vuex store
* @returns {Object} the same stage with fields adjusted for the value stream form
*/
export const generateInitialStageData = (defaultStageConfig, selectedValueStreamStages) =>
selectedValueStreamStages.map(
({ startEventIdentifier = null, endEventIdentifier = null, custom = false, ...rest }) => {
const stageData =
custom && startEventIdentifier && endEventIdentifier
? prepareCustomStage({ ...rest, startEventIdentifier, endEventIdentifier })
: prepareDefaultStage(defaultStageConfig, rest);
if (stageData?.name) {
return {
...stageData,
custom,
};
}
return {};
},
);
...@@ -13,7 +13,7 @@ import { mapGetters, mapState } from 'vuex'; ...@@ -13,7 +13,7 @@ import { mapGetters, mapState } from 'vuex';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import { STAGE_ACTIONS } from '../constants'; import { STAGE_ACTIONS } from '../constants';
import { getAllowedEndEvents, getLabelEventsIdentifiers, isLabelEvent } from '../utils'; import { getAllowedEndEvents, getLabelEventsIdentifiers, isLabelEvent } from '../utils';
import { defaultFields, ERRORS, I18N } from './create_value_stream_form/constants'; import { defaultFields, ERRORS, i18n } from './create_value_stream_form/constants';
import CustomStageFormFields from './create_value_stream_form/custom_stage_fields.vue'; import CustomStageFormFields from './create_value_stream_form/custom_stage_fields.vue';
import { validateStage, initializeFormData } from './create_value_stream_form/utils'; import { validateStage, initializeFormData } from './create_value_stream_form/utils';
...@@ -99,10 +99,10 @@ export default { ...@@ -99,10 +99,10 @@ export default {
return !endEvents.length || !endEvents.includes(endEventIdentifier); return !endEvents.length || !endEvents.includes(endEventIdentifier);
}, },
saveStageText() { saveStageText() {
return this.isEditingCustomStage ? I18N.BTN_UPDATE_STAGE : I18N.BTN_ADD_STAGE; return this.isEditingCustomStage ? i18n.BTN_UPDATE_STAGE : i18n.BTN_ADD_STAGE;
}, },
formTitle() { formTitle() {
return this.isEditingCustomStage ? I18N.TITLE_EDIT_STAGE : I18N.TITLE_ADD_STAGE; return this.isEditingCustomStage ? i18n.TITLE_EDIT_STAGE : i18n.TITLE_ADD_STAGE;
}, },
hasHiddenStages() { hasHiddenStages() {
return this.hiddenStages.length; return this.hiddenStages.length;
...@@ -167,7 +167,7 @@ export default { ...@@ -167,7 +167,7 @@ export default {
Vue.set(this, 'errors', newErrors); Vue.set(this, 'errors', newErrors);
}, },
}, },
I18N, i18n,
}; };
</script> </script>
<template> <template>
...@@ -178,12 +178,12 @@ export default { ...@@ -178,12 +178,12 @@ export default {
<div class="gl-mb-1 gl-display-flex gl-justify-content-space-between gl-align-items-center"> <div class="gl-mb-1 gl-display-flex gl-justify-content-space-between gl-align-items-center">
<h4>{{ formTitle }}</h4> <h4>{{ formTitle }}</h4>
<gl-dropdown <gl-dropdown
:text="$options.I18N.RECOVER_HIDDEN_STAGE" :text="$options.i18n.RECOVER_HIDDEN_STAGE"
data-testid="recover-hidden-stage-dropdown" data-testid="recover-hidden-stage-dropdown"
right right
> >
<gl-dropdown-section-header>{{ <gl-dropdown-section-header>{{
$options.I18N.RECOVER_STAGE_TITLE $options.i18n.RECOVER_STAGE_TITLE
}}</gl-dropdown-section-header> }}</gl-dropdown-section-header>
<template v-if="hasHiddenStages"> <template v-if="hasHiddenStages">
<gl-dropdown-item <gl-dropdown-item
...@@ -193,7 +193,7 @@ export default { ...@@ -193,7 +193,7 @@ export default {
>{{ stage.title }}</gl-dropdown-item >{{ stage.title }}</gl-dropdown-item
> >
</template> </template>
<p v-else class="gl-mx-5 gl-my-3">{{ $options.I18N.RECOVER_STAGES_VISIBLE }}</p> <p v-else class="gl-mx-5 gl-my-3">{{ $options.i18n.RECOVER_STAGES_VISIBLE }}</p>
</gl-dropdown> </gl-dropdown>
</div> </div>
<custom-stage-form-fields <custom-stage-form-fields
...@@ -212,7 +212,7 @@ export default { ...@@ -212,7 +212,7 @@ export default {
data-testid="cancel-custom-stage" data-testid="cancel-custom-stage"
@click="handleCancel" @click="handleCancel"
> >
{{ $options.I18N.BTN_CANCEL }} {{ $options.i18n.BTN_CANCEL }}
</gl-button> </gl-button>
<gl-button <gl-button
:disabled="!isComplete || !isDirty" :disabled="!isComplete || !isDirty"
......
<script> <script>
import { GlButton, GlForm, GlFormInput, GlFormGroup, GlFormRadioGroup, GlModal } from '@gitlab/ui'; import { GlButton, GlForm, GlFormInput, GlFormGroup, GlFormRadioGroup, GlModal } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import Vue from 'vue'; import Vue from 'vue';
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { swapArrayItems } from '~/lib/utils/array_utility'; import { swapArrayItems } from '~/lib/utils/array_utility';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import { sprintf } from '~/locale'; import { sprintf } from '~/locale';
import { import {
STAGE_SORT_DIRECTION, STAGE_SORT_DIRECTION,
I18N, i18n,
defaultCustomStageFields, defaultCustomStageFields,
PRESET_OPTIONS, PRESET_OPTIONS,
PRESET_OPTIONS_DEFAULT, PRESET_OPTIONS_DEFAULT,
} from './create_value_stream_form/constants'; } from './create_value_stream_form/constants';
import CustomStageFields from './create_value_stream_form/custom_stage_fields.vue'; import CustomStageFields from './create_value_stream_form/custom_stage_fields.vue';
import DefaultStageFields from './create_value_stream_form/default_stage_fields.vue'; import DefaultStageFields from './create_value_stream_form/default_stage_fields.vue';
import { validateValueStreamName, validateStage } from './create_value_stream_form/utils'; import {
validateValueStreamName,
validateStage,
formatStageDataForSubmission,
hasDirtyStage,
} from './create_value_stream_form/utils';
const initializeStageErrors = (defaultStageConfig, selectedPreset = PRESET_OPTIONS_DEFAULT) => const initializeStageErrors = (defaultStageConfig, selectedPreset = PRESET_OPTIONS_DEFAULT) =>
selectedPreset === PRESET_OPTIONS_DEFAULT ? defaultStageConfig.map(() => ({})) : [{}]; selectedPreset === PRESET_OPTIONS_DEFAULT ? defaultStageConfig.map(() => ({})) : [{}];
...@@ -24,16 +29,6 @@ const initializeStages = (defaultStageConfig, selectedPreset = PRESET_OPTIONS_DE ...@@ -24,16 +29,6 @@ const initializeStages = (defaultStageConfig, selectedPreset = PRESET_OPTIONS_DE
? defaultStageConfig ? defaultStageConfig
: [{ ...defaultCustomStageFields }]; : [{ ...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: {
...@@ -71,21 +66,28 @@ export default { ...@@ -71,21 +66,28 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
isEditing: {
type: Boolean,
required: false,
default: false,
},
}, },
data() { data() {
const { const {
defaultStageConfig = [], defaultStageConfig = [],
hasExtendedFormFields, hasExtendedFormFields,
initialData, initialData: { name: initialName, stages: initialStages },
initialFormErrors, initialFormErrors,
initialPreset, initialPreset,
} = this; } = this;
const { name: nameError = [], stages: stageErrors = [{}] } = initialFormErrors; const { name: nameError = [], stages: stageErrors = [{}] } = initialFormErrors;
const additionalFields = hasExtendedFormFields const additionalFields = hasExtendedFormFields
? { ? {
stages: initializeStages(defaultStageConfig, initialPreset), stages: this.isEditing
stageErrors: stageErrors || initializeStageErrors(defaultStageConfig, initialPreset), ? cloneDeep(initialStages)
...initialData, : initializeStages(defaultStageConfig, initialPreset),
stageErrors:
cloneDeep(stageErrors) || initializeStageErrors(defaultStageConfig, initialPreset),
} }
: { stages: [], nameError }; : { stages: [], nameError };
...@@ -93,7 +95,7 @@ export default { ...@@ -93,7 +95,7 @@ export default {
hiddenStages: [], hiddenStages: [],
selectedPreset: initialPreset, selectedPreset: initialPreset,
presetOptions: PRESET_OPTIONS, presetOptions: PRESET_OPTIONS,
name: '', name: initialName,
nameError, nameError,
stageErrors, stageErrors,
...additionalFields, ...additionalFields,
...@@ -115,15 +117,18 @@ export default { ...@@ -115,15 +117,18 @@ export default {
isLoading() { isLoading() {
return this.isCreating; return this.isCreating;
}, },
formTitle() {
return this.isEditing ? this.$options.i18n.EDIT_FORM_TITLE : this.$options.i18n.FORM_TITLE;
},
primaryProps() { primaryProps() {
return { return {
text: this.$options.I18N.FORM_TITLE, text: this.isEditing ? this.$options.i18n.EDIT_FORM_ACTION : this.$options.i18n.FORM_TITLE,
attributes: [{ variant: 'success' }, { loading: this.isLoading }], attributes: [{ variant: 'success' }, { loading: this.isLoading }],
}; };
}, },
secondaryProps() { secondaryProps() {
return { return {
text: this.$options.I18N.BTN_ADD_ANOTHER_STAGE, text: this.$options.i18n.BTN_ADD_ANOTHER_STAGE,
attributes: [ attributes: [
{ category: 'secondary' }, { category: 'secondary' },
{ variant: 'info' }, { variant: 'info' },
...@@ -136,24 +141,42 @@ export default { ...@@ -136,24 +141,42 @@ export default {
this.nameError.length || this.stageErrors.some((obj) => Object.keys(obj).length), this.nameError.length || this.stageErrors.some((obj) => Object.keys(obj).length),
); );
}, },
isDirtyEditing() {
return (
this.isEditing &&
(this.hasDirtyName(this.name, this.initialData.name) ||
hasDirtyStage(this.stages, this.initialData.stages))
);
}, },
watch: { canRestore() {
initialFormErrors({ name: nameError, stages: stageErrors }) { return this.hiddenStages.length || this.isDirtyEditing;
Vue.set(this, 'nameError', nameError);
Vue.set(this, 'stageErrors', stageErrors);
}, },
}, },
methods: { methods: {
...mapActions(['createValueStream']), ...mapActions(['createValueStream', 'updateValueStream']),
onSubmit() { onSubmit() {
this.validate(); this.validate();
if (this.hasFormErrors) return false; if (this.hasFormErrors) return false;
return this.createValueStream({
let req = this.createValueStream;
let params = {
name: this.name, name: this.name,
stages: formatStageDataForSubmission(this.stages), stages: formatStageDataForSubmission(this.stages, this.isEditing),
}).then(() => { };
if (this.isEditing) {
req = this.updateValueStream;
params = {
...params,
id: this.initialData.id,
};
}
return req(params).then(() => {
if (!this.hasInitialFormErrors) { if (!this.hasInitialFormErrors) {
this.$toast.show(sprintf(this.$options.I18N.FORM_CREATED, { name: this.name }), { const msg = this.isEditing
? this.$options.i18n.FORM_EDITED
: this.$options.i18n.FORM_CREATED;
this.$toast.show(sprintf(msg, { name: this.name }), {
position: 'top-center', position: 'top-center',
}); });
this.name = ''; this.name = '';
...@@ -169,10 +192,13 @@ export default { ...@@ -169,10 +192,13 @@ export default {
: `custom-template-stage-${index}`; : `custom-template-stage-${index}`;
}, },
stageGroupLabel(index) { stageGroupLabel(index) {
return sprintf(this.$options.I18N.STAGE_INDEX, { index: index + 1 }); return sprintf(this.$options.i18n.STAGE_INDEX, { index: index + 1 });
}, },
recoverStageTitle(name) { recoverStageTitle(name) {
return sprintf(this.$options.I18N.HIDDEN_DEFAULT_STAGE, { name }); return sprintf(this.$options.i18n.HIDDEN_DEFAULT_STAGE, { name });
},
hasDirtyName(current, original) {
return current.trim().toLowerCase() !== original.trim().toLowerCase();
}, },
validateStages() { validateStages() {
return this.stages.map(validateStage); return this.stages.map(validateStage);
...@@ -190,8 +216,8 @@ export default { ...@@ -190,8 +216,8 @@ export default {
handleMove({ index, direction }) { handleMove({ index, direction }) {
const newStages = this.moveItem(this.stages, index, direction); const newStages = this.moveItem(this.stages, index, direction);
const newErrors = this.moveItem(this.stageErrors, index, direction); const newErrors = this.moveItem(this.stageErrors, index, direction);
Vue.set(this, 'stageErrors', newErrors); Vue.set(this, 'stageErrors', cloneDeep(newErrors));
Vue.set(this, 'stages', newStages); Vue.set(this, 'stages', cloneDeep(newStages));
}, },
validateStageFields(index) { validateStageFields(index) {
Vue.set(this.stageErrors, index, validateStage(this.stages[index])); Vue.set(this.stageErrors, index, validateStage(this.stages[index]));
...@@ -228,10 +254,20 @@ export default { ...@@ -228,10 +254,20 @@ export default {
Vue.set(this.stages, activeStageIndex, updatedStage); Vue.set(this.stages, activeStageIndex, updatedStage);
}, },
handleResetDefaults() { handleResetDefaults() {
if (this.isEditing) {
const {
initialData: { name: initialName, stages: initialStages },
} = this;
Vue.set(this, 'name', initialName);
Vue.set(this, 'nameError', []);
Vue.set(this, 'stages', cloneDeep(initialStages));
Vue.set(this, 'stageErrors', [{}]);
} else {
this.name = ''; this.name = '';
this.defaultStageConfig.forEach((stage, index) => { this.defaultStageConfig.forEach((stage, index) => {
Vue.set(this.stages, index, { ...stage, hidden: false }); Vue.set(this.stages, index, { ...stage, hidden: false });
}); });
}
}, },
handleResetBlank() { handleResetBlank() {
this.name = ''; this.name = '';
...@@ -250,7 +286,7 @@ export default { ...@@ -250,7 +286,7 @@ export default {
); );
}, },
}, },
I18N, i18n,
}; };
</script> </script>
<template> <template>
...@@ -259,10 +295,11 @@ export default { ...@@ -259,10 +295,11 @@ export default {
modal-id="value-stream-form-modal" modal-id="value-stream-form-modal"
dialog-class="gl-align-items-flex-start! gl-py-7" dialog-class="gl-align-items-flex-start! gl-py-7"
scrollable scrollable
:title="$options.I18N.FORM_TITLE" :title="formTitle"
:action-primary="primaryProps" :action-primary="primaryProps"
:action-secondary="secondaryProps" :action-secondary="secondaryProps"
:action-cancel="{ text: $options.I18N.BTN_CANCEL }" :action-cancel="{ text: $options.i18n.BTN_CANCEL }"
@hidden.prevent="$emit('hidden')"
@secondary.prevent="onAddStage" @secondary.prevent="onAddStage"
@primary.prevent="onSubmit" @primary.prevent="onSubmit"
> >
...@@ -270,7 +307,7 @@ export default { ...@@ -270,7 +307,7 @@ export default {
<gl-form-group <gl-form-group
data-testid="create-value-stream-name" 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="invalidNameFeedback" :invalid-feedback="invalidNameFeedback"
:state="isValueStreamNameValid" :state="isValueStreamNameValid"
> >
...@@ -279,21 +316,21 @@ export default { ...@@ -279,21 +316,21 @@ export default {
id="create-value-stream-name" id="create-value-stream-name"
v-model.trim="name" v-model.trim="name"
name="create-value-stream-name" name="create-value-stream-name"
:placeholder="$options.I18N.FORM_FIELD_NAME_PLACEHOLDER" :placeholder="$options.i18n.FORM_FIELD_NAME_PLACEHOLDER"
:state="isValueStreamNameValid" :state="isValueStreamNameValid"
required required
/> />
<gl-button <gl-button
v-if="hiddenStages.length" v-if="canRestore"
class="gl-ml-3" class="gl-ml-3"
variant="link" variant="link"
@click="handleResetDefaults" @click="handleResetDefaults"
>{{ $options.I18N.RESTORE_DEFAULTS }}</gl-button >{{ $options.i18n.RESTORE_DEFAULTS }}</gl-button
> >
</div> </div>
</gl-form-group> </gl-form-group>
<gl-form-radio-group <gl-form-radio-group
v-if="hasExtendedFormFields" v-if="hasExtendedFormFields && !isEditing"
v-model="selectedPreset" v-model="selectedPreset"
class="gl-mb-4" class="gl-mb-4"
data-testid="vsa-preset-selector" data-testid="vsa-preset-selector"
...@@ -338,7 +375,7 @@ export default { ...@@ -338,7 +375,7 @@ export default {
recoverStageTitle(stage.name) recoverStageTitle(stage.name)
}}</span> }}</span>
<gl-button variant="link" @click="onRestore(hiddenStageIndex)">{{ <gl-button variant="link" @click="onRestore(hiddenStageIndex)">{{
$options.I18N.RESTORE_HIDDEN_STAGE $options.i18n.RESTORE_HIDDEN_STAGE
}}</gl-button> }}</gl-button>
</gl-form-group> </gl-form-group>
</div> </div>
......
...@@ -9,16 +9,20 @@ import { ...@@ -9,16 +9,20 @@ import {
GlModalDirective, GlModalDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { sprintf, __ } from '~/locale'; import { sprintf, __, s__ } from '~/locale';
import { generateInitialStageData } from './create_value_stream_form/utils';
import ValueStreamForm from './value_stream_form.vue'; import ValueStreamForm from './value_stream_form.vue';
const I18N = { const i18n = {
DELETE_NAME: __('Delete %{name}'), DELETE_NAME: s__('DeleteValueStream|Delete %{name}'),
DELETE_CONFIRMATION: __('Are you sure you want to delete "%{name}" Value Stream?'), DELETE_CONFIRMATION: s__(
DELETED: __("'%{name}' Value Stream deleted"), 'DeleteValueStream|Are you sure you want to delete "%{name}" Value Stream?',
),
DELETED: s__("DeleteValueStream|'%{name}' Value Stream deleted"),
DELETE: __('Delete'), DELETE: __('Delete'),
CREATE_VALUE_STREAM: __('Create new Value Stream'), CREATE_VALUE_STREAM: s__('CreateValueStreamForm|Create new Value Stream'),
CANCEL: __('Cancel'), CANCEL: __('Cancel'),
EDIT_VALUE_STREAM: __('Edit'),
}; };
export default { export default {
...@@ -41,12 +45,23 @@ export default { ...@@ -41,12 +45,23 @@ export default {
default: false, default: false,
}, },
}, },
data() {
return {
showCreateModal: false,
isEditing: false,
initialData: {
name: '',
stages: [],
},
};
},
computed: { computed: {
...mapState({ ...mapState({
isDeleting: 'isDeletingValueStream', isDeleting: 'isDeletingValueStream',
deleteValueStreamError: 'deleteValueStreamError', deleteValueStreamError: 'deleteValueStreamError',
data: 'valueStreams', data: 'valueStreams',
selectedValueStream: 'selectedValueStream', selectedValueStream: 'selectedValueStream',
selectedValueStreamStages: 'stages',
initialFormErrors: 'createValueStreamErrors', initialFormErrors: 'createValueStreamErrors',
defaultStageConfig: 'defaultStageConfig', defaultStageConfig: 'defaultStageConfig',
}), }),
...@@ -59,14 +74,14 @@ export default { ...@@ -59,14 +74,14 @@ export default {
selectedValueStreamId() { selectedValueStreamId() {
return this.selectedValueStream?.id || null; return this.selectedValueStream?.id || null;
}, },
canDeleteSelectedStage() { isCustomValueStream() {
return this.selectedValueStream?.isCustom || false; return this.selectedValueStream?.isCustom || false;
}, },
deleteSelectedText() { deleteSelectedText() {
return sprintf(this.$options.I18N.DELETE_NAME, { name: this.selectedValueStreamName }); return sprintf(this.$options.i18n.DELETE_NAME, { name: this.selectedValueStreamName });
}, },
deleteConfirmationText() { deleteConfirmationText() {
return sprintf(this.$options.I18N.DELETE_CONFIRMATION, { return sprintf(this.$options.i18n.DELETE_CONFIRMATION, {
name: this.selectedValueStreamName, name: this.selectedValueStreamName,
}); });
}, },
...@@ -86,16 +101,39 @@ export default { ...@@ -86,16 +101,39 @@ export default {
const name = this.selectedValueStreamName; const name = this.selectedValueStreamName;
return this.deleteValueStream(this.selectedValueStreamId).then(() => { return this.deleteValueStream(this.selectedValueStreamId).then(() => {
if (!this.deleteValueStreamError) { if (!this.deleteValueStreamError) {
this.onSuccess(sprintf(this.$options.I18N.DELETED, { name })); this.onSuccess(sprintf(this.$options.i18n.DELETED, { name }));
} }
}); });
}, },
onCreate() {
this.showCreateModal = true;
this.isEditing = false;
this.initialData = {
name: '',
stages: [],
};
},
onEdit() {
this.showCreateModal = true;
this.isEditing = true;
this.initialData = {
...this.selectedValueStream,
stages: generateInitialStageData(this.defaultStageConfig, this.selectedValueStreamStages),
};
},
}, },
I18N, i18n,
}; };
</script> </script>
<template> <template>
<div> <div>
<gl-button
v-if="isCustomValueStream"
v-gl-modal-directive="'value-stream-form-modal'"
data-testid="edit-value-stream"
@click="onEdit"
>{{ $options.i18n.EDIT_VALUE_STREAM }}</gl-button
>
<gl-dropdown <gl-dropdown
v-if="hasValueStreams" v-if="hasValueStreams"
data-testid="dropdown-value-streams" data-testid="dropdown-value-streams"
...@@ -111,34 +149,45 @@ export default { ...@@ -111,34 +149,45 @@ export default {
>{{ streamName }}</gl-dropdown-item >{{ streamName }}</gl-dropdown-item
> >
<gl-dropdown-divider /> <gl-dropdown-divider />
<gl-dropdown-item v-gl-modal-directive="'value-stream-form-modal'">{{
$options.I18N.CREATE_VALUE_STREAM
}}</gl-dropdown-item>
<gl-dropdown-item <gl-dropdown-item
v-if="canDeleteSelectedStage" v-gl-modal-directive="'value-stream-form-modal'"
data-testid="create-value-stream"
@click="onCreate"
>{{ $options.i18n.CREATE_VALUE_STREAM }}</gl-dropdown-item
>
<gl-dropdown-item
v-if="isCustomValueStream"
v-gl-modal-directive="'delete-value-stream-modal'" v-gl-modal-directive="'delete-value-stream-modal'"
variant="danger" variant="danger"
data-testid="delete-value-stream" data-testid="delete-value-stream"
>{{ deleteSelectedText }}</gl-dropdown-item >{{ deleteSelectedText }}</gl-dropdown-item
> >
</gl-dropdown> </gl-dropdown>
<gl-button v-else v-gl-modal-directive="'value-stream-form-modal'">{{ <gl-button
$options.I18N.CREATE_VALUE_STREAM v-else
}}</gl-button> v-gl-modal-directive="'value-stream-form-modal'"
data-testid="create-value-stream-button"
@click="onCreate"
>{{ $options.i18n.CREATE_VALUE_STREAM }}</gl-button
>
<value-stream-form <value-stream-form
v-if="showCreateModal"
:initial-data="initialData"
:initial-form-errors="initialFormErrors" :initial-form-errors="initialFormErrors"
:has-extended-form-fields="hasExtendedFormFields" :has-extended-form-fields="hasExtendedFormFields"
:default-stage-config="defaultStageConfig" :default-stage-config="defaultStageConfig"
:is-editing="isEditing"
@hidden="showCreateModal = false"
/> />
<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"
:title="__('Delete Value Stream')" :title="__('Delete Value Stream')"
:action-primary="{ :action-primary="{
text: $options.I18N.DELETE, text: $options.i18n.DELETE,
attributes: [{ variant: 'danger' }, { loading: isDeleting }], attributes: [{ variant: 'danger' }, { loading: isDeleting }],
}" }"
:action-cancel="{ text: $options.I18N.CANCEL }" :action-cancel="{ text: $options.i18n.CANCEL }"
@primary.prevent="onDelete" @primary.prevent="onDelete"
> >
<gl-alert v-if="deleteValueStreamError" variant="danger">{{ <gl-alert v-if="deleteValueStreamError" variant="danger">{{
......
...@@ -6,6 +6,9 @@ import { ...@@ -6,6 +6,9 @@ import {
initializeFormData, initializeFormData,
validateStage, validateStage,
validateValueStreamName, validateValueStreamName,
hasDirtyStage,
formatStageDataForSubmission,
generateInitialStageData,
} from 'ee/analytics/cycle_analytics/components/create_value_stream_form/utils'; } from 'ee/analytics/cycle_analytics/components/create_value_stream_form/utils';
import { emptyErrorsState, emptyState, formInitialData } from './mock_data'; import { emptyErrorsState, emptyState, formInitialData } from './mock_data';
...@@ -109,7 +112,7 @@ describe('validateStage', () => { ...@@ -109,7 +112,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([]); expect(validateValueStreamName({ name: 'Cool stream name' })).toEqual([]);
}); });
...@@ -123,3 +126,176 @@ describe('validateValueStreamName,', () => { ...@@ -123,3 +126,176 @@ describe('validateValueStreamName,', () => {
expect(result).toEqual([error]); expect(result).toEqual([error]);
}); });
}); });
describe('hasDirtyStage', () => {
const fakeStages = [
{ id: 10, name: 'Fake new stage', startEventIdentifier: 'issue_created' },
{ id: null, name: 'Fake new stage 2' },
];
it('will return false if all required fields are equal for all stages', () => {
expect(hasDirtyStage(fakeStages, fakeStages)).toBe(false);
});
it('will return true if any stage field value is different', () => {
expect(hasDirtyStage(fakeStages, [{}, ...fakeStages])).toBe(true);
expect(hasDirtyStage(fakeStages, [fakeStages, {}])).toBe(true);
expect(
hasDirtyStage(fakeStages, [
fakeStages[0],
{ ...fakeStages[1], endEventIdentifier: 'issue_closed' },
]),
).toBe(true);
});
it('will ignore fields that are not required for the form', () => {
expect(
hasDirtyStage(fakeStages, [fakeStages[0], { ...fakeStages[1], fakeField: 'issue_closed' }]),
).toBe(false);
});
});
describe('formatStageDataForSubmission', () => {
let res = {};
const fakeStage = {
id: null,
name: 'Fake new stage',
startEventIdentifier: 'issue_created',
endEventIdentifier: 'issue_closed',
startEventLabelId: 'label A',
endEventLabelId: 'label B',
};
describe('default stages', () => {
beforeEach(() => {
[res] = formatStageDataForSubmission([fakeStage]);
});
it('will not include the `id`', () => {
expect(Object.keys(res).includes('id')).toBe(false);
});
it('will not include the event fields', () => {
[
'start_event_identifier',
'start_event_label_id',
'end_event_identifier',
'end_event_label_id',
].forEach((field) => {
expect(Object.keys(res).includes(field)).toBe(false);
});
});
it('will convert all properties to snake case', () => {
expect(Object.keys(res)).toEqual(['custom', 'name']);
});
});
describe('with a custom stage', () => {
beforeEach(() => {
[res] = formatStageDataForSubmission([{ ...fakeStage, custom: true }]);
});
it('will convert all properties to snake case', () => {
expect(Object.keys(res)).toEqual([
'start_event_identifier',
'end_event_identifier',
'start_event_label_id',
'end_event_label_id',
'custom',
'name',
]);
});
it('will include the event fields', () => {
[
'start_event_identifier',
'start_event_label_id',
'end_event_identifier',
'end_event_label_id',
].forEach((field) => {
expect(Object.keys(res).includes(field)).toBe(true);
});
});
});
describe('isEditing = true ', () => {
it('will include the `id` if it has a value', () => {
[res] = formatStageDataForSubmission([{ ...fakeStage, id: 10 }], true);
expect(Object.keys(res).includes('id')).toBe(true);
});
it('will set custom to `true`', () => {
[res] = formatStageDataForSubmission([fakeStage], true);
expect(res.custom).toBe(true);
});
});
});
describe('generateInitialStageData', () => {
const defaultConfig = {
name: 'issue',
custom: false,
startEventIdentifier: 'issue_created',
endEventIdentifier: 'issue_stage_end',
};
const initialDefaultStage = {
id: 0,
name: 'issue',
startEventIdentifier: null,
endEventIdentifier: null,
custom: false,
};
const initialCustomStage = {
id: 2,
name: 'custom issue',
startEventIdentifier: 'merge_request_created',
endEventIdentifier: 'merge_request_closed',
startEventLabel: {
id: 'label_added',
},
endEventLabel: {
id: 'label_removed',
},
custom: true,
};
describe('valid default stages', () => {
it.each`
key | value
${'startEventIdentifier'} | ${defaultConfig.startEventIdentifier}
${'endEventIdentifier'} | ${defaultConfig.endEventIdentifier}
${'isDefault'} | ${true}
`('sets the $key field', ({ key, value }) => {
const [res] = generateInitialStageData([defaultConfig], [initialDefaultStage]);
expect(res[key]).toEqual(value);
});
});
it('will return an empty object for an invalid default stages', () => {
const [res] = generateInitialStageData(
[defaultConfig],
[{ ...initialDefaultStage, name: 'issue-fake' }],
);
expect(res).toEqual({});
});
describe('custom stages', () => {
it.each`
key | value
${'startEventIdentifier'} | ${initialCustomStage.startEventIdentifier}
${'endEventIdentifier'} | ${initialCustomStage.endEventIdentifier}
${'startEventLabelId'} | ${initialCustomStage.startEventLabel.id}
${'endEventLabelId'} | ${initialCustomStage.endEventLabel.id}
${'isDefault'} | ${false}
`('sets the $key field', ({ key, value }) => {
const [res] = generateInitialStageData([defaultConfig], [initialCustomStage]);
expect(res[key]).toEqual(value);
});
});
});
import { GlModal } from '@gitlab/ui'; import { GlModal, GlFormInput } 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 { PRESET_OPTIONS_BLANK } from 'ee/analytics/cycle_analytics/components/create_value_stream_form/constants'; import { PRESET_OPTIONS_BLANK } from 'ee/analytics/cycle_analytics/components/create_value_stream_form/constants';
...@@ -6,7 +6,11 @@ import CustomStageFields from 'ee/analytics/cycle_analytics/components/create_va ...@@ -6,7 +6,11 @@ import CustomStageFields from 'ee/analytics/cycle_analytics/components/create_va
import DefaultStageFields from 'ee/analytics/cycle_analytics/components/create_value_stream_form/default_stage_fields.vue'; import DefaultStageFields from 'ee/analytics/cycle_analytics/components/create_value_stream_form/default_stage_fields.vue';
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 { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { customStageEvents as formEvents, defaultStageConfig } from '../mock_data'; import {
convertObjectPropsToCamelCase,
convertObjectPropsToSnakeCase,
} from '~/lib/utils/common_utils';
import { customStageEvents as formEvents, defaultStageConfig, rawCustomStage } from '../mock_data';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -15,6 +19,7 @@ describe('ValueStreamForm', () => { ...@@ -15,6 +19,7 @@ describe('ValueStreamForm', () => {
let wrapper = null; let wrapper = null;
const createValueStreamMock = jest.fn(() => Promise.resolve()); const createValueStreamMock = jest.fn(() => Promise.resolve());
const updateValueStreamMock = jest.fn(() => Promise.resolve());
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';
...@@ -28,6 +33,14 @@ describe('ValueStreamForm', () => { ...@@ -28,6 +33,14 @@ describe('ValueStreamForm', () => {
], ],
}; };
const initialData = {
stages: [convertObjectPropsToCamelCase(rawCustomStage)],
id: 1337,
name: 'Editable value stream',
};
const initialPreset = PRESET_OPTIONS_BLANK;
const fakeStore = () => const fakeStore = () =>
new Vuex.Store({ new Vuex.Store({
state: { state: {
...@@ -35,6 +48,7 @@ describe('ValueStreamForm', () => { ...@@ -35,6 +48,7 @@ describe('ValueStreamForm', () => {
}, },
actions: { actions: {
createValueStream: createValueStreamMock, createValueStream: createValueStreamMock,
updateValueStream: updateValueStreamMock,
}, },
modules: { modules: {
customStages: { customStages: {
...@@ -71,14 +85,13 @@ describe('ValueStreamForm', () => { ...@@ -71,14 +85,13 @@ describe('ValueStreamForm', () => {
}), }),
); );
const findModal = () => wrapper.find(GlModal); const findModal = () => wrapper.findComponent(GlModal);
const clickSubmit = () => findModal().vm.$emit('primary', mockEvent); const clickSubmit = () => findModal().vm.$emit('primary', mockEvent);
const clickAddStage = () => findModal().vm.$emit('secondary', mockEvent); const clickAddStage = () => findModal().vm.$emit('secondary', mockEvent);
const findExtendedFormFields = () => wrapper.findByTestId('extended-form-fields'); const findExtendedFormFields = () => wrapper.findByTestId('extended-form-fields');
const findPresetSelector = () => wrapper.findByTestId('vsa-preset-selector'); const findPresetSelector = () => wrapper.findByTestId('vsa-preset-selector');
const findBtn = (btn) => findModal().props(btn); const findBtn = (btn) => findModal().props(btn);
const findSubmitDisabledAttribute = (attribute) => const findSubmitAttribute = (attribute) => findBtn('actionPrimary').attributes[1][attribute];
findBtn('actionPrimary').attributes[1][attribute];
const expectFieldError = (testId, error = '') => const expectFieldError = (testId, error = '') =>
expect(wrapper.findByTestId(testId).attributes('invalid-feedback')).toBe(error); expect(wrapper.findByTestId(testId).attributes('invalid-feedback')).toBe(error);
...@@ -93,7 +106,7 @@ describe('ValueStreamForm', () => { ...@@ -93,7 +106,7 @@ describe('ValueStreamForm', () => {
}); });
it('submit button is enabled', () => { it('submit button is enabled', () => {
expect(findSubmitDisabledAttribute('disabled')).toBeUndefined(); expect(findSubmitAttribute('disabled')).toBeUndefined();
}); });
it('does not include extended fields', () => { it('does not include extended fields', () => {
...@@ -120,6 +133,10 @@ describe('ValueStreamForm', () => { ...@@ -120,6 +133,10 @@ describe('ValueStreamForm', () => {
expect(findExtendedFormFields().exists()).toBe(true); expect(findExtendedFormFields().exists()).toBe(true);
}); });
it('sets the submit action text to "Create Value Stream"', () => {
expect(findBtn('actionPrimary').text).toBe('Create Value Stream');
});
describe('Preset selector', () => { describe('Preset selector', () => {
it('has the preset button', () => { it('has the preset button', () => {
expect(findPresetSelector().exists()).toBe(true); expect(findPresetSelector().exists()).toBe(true);
...@@ -185,6 +202,114 @@ describe('ValueStreamForm', () => { ...@@ -185,6 +202,114 @@ describe('ValueStreamForm', () => {
); );
}); });
}); });
describe('isEditing=true', () => {
const stageCount = initialData.stages.length;
beforeEach(() => {
wrapper = createComponent({
props: {
initialPreset,
initialData,
isEditing: true,
hasExtendedFormFields: true,
},
});
});
it('does not have the preset button', () => {
expect(findPresetSelector().exists()).toBe(false);
});
it('sets the submit action text to "Save Value Stream"', () => {
expect(findBtn('actionPrimary').text).toBe('Save Value Stream');
});
describe('Add stage button', () => {
it('has the add stage button', () => {
expect(findBtn('actionSecondary')).toMatchObject({ text: 'Add another stage' });
});
it('adds a blank custom stage when clicked', () => {
expect(wrapper.vm.stages.length).toBe(stageCount);
clickAddStage();
expect(wrapper.vm.stages.length).toBe(stageCount + 1);
});
it('validates existing fields when clicked', () => {
expect(wrapper.vm.nameError).toEqual([]);
wrapper.findByTestId('create-value-stream-name').find(GlFormInput).vm.$emit('input', '');
clickAddStage();
expect(wrapper.vm.nameError).toEqual(['Name is required']);
});
});
describe('with valid fields', () => {
beforeEach(() => {
wrapper = createComponent({
props: {
initialPreset,
initialData,
isEditing: true,
hasExtendedFormFields: true,
},
});
});
describe('form submitted successfully', () => {
beforeEach(() => {
clickSubmit();
});
it('calls the "updateValueStreamMock" event when submitted', () => {
expect(updateValueStreamMock).toHaveBeenCalledWith(expect.any(Object), {
...initialData,
stages: initialData.stages.map((stage) =>
convertObjectPropsToSnakeCase(stage, { deep: true }),
),
});
});
it('displays a toast message', () => {
expect(mockToastShow).toHaveBeenCalledWith(
`'${initialData.name}' Value Stream edited`,
{
position: 'top-center',
},
);
});
});
describe('form submission fails', () => {
beforeEach(() => {
wrapper = createComponent({
data: { name: streamName },
props: {
initialFormErrors,
},
});
clickSubmit();
});
it('does not call the updateValueStreamMock action', () => {
expect(updateValueStreamMock).not.toHaveBeenCalled();
});
it('does not clear the name field', () => {
expect(wrapper.vm.name).toBe(streamName);
});
it('does not display a toast message', () => {
expect(mockToastShow).not.toHaveBeenCalled();
});
});
});
});
}); });
describe('form errors', () => { describe('form errors', () => {
......
import { GlButton, GlDropdown } from '@gitlab/ui'; import { GlDropdown } 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 ValueStreamSelect from 'ee/analytics/cycle_analytics/components/value_stream_select.vue'; import ValueStreamSelect from 'ee/analytics/cycle_analytics/components/value_stream_select.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { findDropdownItemText } from '../helpers'; import { findDropdownItemText } from '../helpers';
import { valueStreams, defaultStageConfig } from '../mock_data'; import { valueStreams, defaultStageConfig } from '../mock_data';
...@@ -11,7 +12,6 @@ localVue.use(Vuex); ...@@ -11,7 +12,6 @@ localVue.use(Vuex);
describe('ValueStreamSelect', () => { describe('ValueStreamSelect', () => {
let wrapper = null; let wrapper = null;
const createValueStreamMock = jest.fn(() => Promise.resolve());
const deleteValueStreamMock = jest.fn(() => Promise.resolve()); const deleteValueStreamMock = jest.fn(() => Promise.resolve());
const mockEvent = { preventDefault: jest.fn() }; const mockEvent = { preventDefault: jest.fn() };
const mockToastShow = jest.fn(); const mockToastShow = jest.fn();
...@@ -32,12 +32,12 @@ describe('ValueStreamSelect', () => { ...@@ -32,12 +32,12 @@ describe('ValueStreamSelect', () => {
...initialState, ...initialState,
}, },
actions: { actions: {
createValueStream: createValueStreamMock,
deleteValueStream: deleteValueStreamMock, deleteValueStream: deleteValueStreamMock,
}, },
}); });
const createComponent = ({ data = {}, initialState = {} } = {}) => const createComponent = ({ data = {}, initialState = {} } = {}) =>
extendedWrapper(
shallowMount(ValueStreamSelect, { shallowMount(ValueStreamSelect, {
localVue, localVue,
store: fakeStore({ initialState }), store: fakeStore({ initialState }),
...@@ -51,14 +51,16 @@ describe('ValueStreamSelect', () => { ...@@ -51,14 +51,16 @@ describe('ValueStreamSelect', () => {
show: mockToastShow, show: mockToastShow,
}, },
}, },
}); }),
);
const findModal = (modal) => wrapper.find(`[data-testid="${modal}-value-stream-modal"]`); const findModal = (modal) => wrapper.find(`[data-testid="${modal}-value-stream-modal"]`);
const submitModal = (modal) => findModal(modal).vm.$emit('primary', mockEvent); const submitModal = (modal) => findModal(modal).vm.$emit('primary', mockEvent);
const findSelectValueStreamDropdown = () => wrapper.find(GlDropdown); const findSelectValueStreamDropdown = () => wrapper.findComponent(GlDropdown);
const findSelectValueStreamDropdownOptions = (_wrapper) => findDropdownItemText(_wrapper); const findSelectValueStreamDropdownOptions = (_wrapper) => findDropdownItemText(_wrapper);
const findCreateValueStreamButton = () => wrapper.find(GlButton); const findCreateValueStreamButton = () => wrapper.findByTestId('create-value-stream-button');
const findDeleteValueStreamButton = () => wrapper.find('[data-testid="delete-value-stream"]'); const findEditValueStreamButton = () => wrapper.findByTestId('edit-value-stream');
const findDeleteValueStreamButton = () => wrapper.findByTestId('delete-value-stream');
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ wrapper = createComponent({
...@@ -89,7 +91,7 @@ describe('ValueStreamSelect', () => { ...@@ -89,7 +91,7 @@ describe('ValueStreamSelect', () => {
}); });
describe('with a selected value stream', () => { describe('with a selected value stream', () => {
it('renders a delete option for custom value streams', () => { beforeEach(() => {
wrapper = createComponent({ wrapper = createComponent({
initialState: { initialState: {
valueStreams, valueStreams,
...@@ -99,21 +101,31 @@ describe('ValueStreamSelect', () => { ...@@ -99,21 +101,31 @@ describe('ValueStreamSelect', () => {
}, },
}, },
}); });
});
it('renders a delete option for custom value streams', () => {
expect(findDeleteValueStreamButton().exists()).toBe(true); expect(findDeleteValueStreamButton().exists()).toBe(true);
expect(findDeleteValueStreamButton().text()).toBe(`Delete ${selectedValueStream.name}`); expect(findDeleteValueStreamButton().text()).toBe(`Delete ${selectedValueStream.name}`);
}); });
it('does not render a delete option for default value streams', () => { it('renders an edit option for custom value streams', () => {
wrapper = createComponent({ expect(findEditValueStreamButton().exists()).toBe(true);
initialState: { expect(findEditValueStreamButton().text()).toBe('Edit');
valueStreams, });
selectedValueStream, });
},
describe('with a default value stream', () => {
beforeEach(() => {
wrapper = createComponent({ initialState: { valueStreams, selectedValueStream } });
}); });
it('does not render a delete option for default value streams', () => {
expect(findDeleteValueStreamButton().exists()).toBe(false); expect(findDeleteValueStreamButton().exists()).toBe(false);
}); });
it('does not render an edit option for default value streams', () => {
expect(findEditValueStreamButton().exists()).toBe(false);
});
}); });
}); });
...@@ -133,6 +145,10 @@ describe('ValueStreamSelect', () => { ...@@ -133,6 +145,10 @@ describe('ValueStreamSelect', () => {
it('displays the select value stream dropdown', () => { it('displays the select value stream dropdown', () => {
expect(findSelectValueStreamDropdown().exists()).toBe(true); expect(findSelectValueStreamDropdown().exists()).toBe(true);
}); });
it('does not render an edit option for default value streams', () => {
expect(findEditValueStreamButton().exists()).toBe(false);
});
}); });
describe('No value streams available', () => { describe('No value streams available', () => {
...@@ -151,6 +167,10 @@ describe('ValueStreamSelect', () => { ...@@ -151,6 +167,10 @@ describe('ValueStreamSelect', () => {
it('does not display the select value stream dropdown', () => { it('does not display the select value stream dropdown', () => {
expect(findSelectValueStreamDropdown().exists()).toBe(false); expect(findSelectValueStreamDropdown().exists()).toBe(false);
}); });
it('does not render an edit option for default value streams', () => {
expect(findEditValueStreamButton().exists()).toBe(false);
});
}); });
describe('Delete value stream modal', () => { describe('Delete value stream modal', () => {
......
...@@ -144,6 +144,7 @@ export const codeEvents = deepCamelCase(stageFixtures.code); ...@@ -144,6 +144,7 @@ export const codeEvents = deepCamelCase(stageFixtures.code);
export const testEvents = deepCamelCase(stageFixtures.test); export const testEvents = deepCamelCase(stageFixtures.test);
export const stagingEvents = deepCamelCase(stageFixtures.staging); export const stagingEvents = deepCamelCase(stageFixtures.staging);
export const rawCustomStage = { export const rawCustomStage = {
name: 'Coolest beans stage',
title: 'Coolest beans stage', title: 'Coolest beans stage',
hidden: false, hidden: false,
legend: '', legend: '',
......
...@@ -3927,9 +3927,6 @@ msgstr "" ...@@ -3927,9 +3927,6 @@ msgstr ""
msgid "Are you sure you want to close this blocked issue?" msgid "Are you sure you want to close this blocked issue?"
msgstr "" msgstr ""
msgid "Are you sure you want to delete \"%{name}\" Value Stream?"
msgstr ""
msgid "Are you sure you want to delete %{name}?" msgid "Are you sure you want to delete %{name}?"
msgstr "" msgstr ""
...@@ -8639,6 +8636,9 @@ msgstr "" ...@@ -8639,6 +8636,9 @@ msgstr ""
msgid "CreateValueStreamForm|'%{name}' Value Stream created" msgid "CreateValueStreamForm|'%{name}' Value Stream created"
msgstr "" msgstr ""
msgid "CreateValueStreamForm|'%{name}' Value Stream edited"
msgstr ""
msgid "CreateValueStreamForm|Add another stage" msgid "CreateValueStreamForm|Add another stage"
msgstr "" msgstr ""
...@@ -8651,15 +8651,24 @@ msgstr "" ...@@ -8651,15 +8651,24 @@ msgstr ""
msgid "CreateValueStreamForm|Code stage start" msgid "CreateValueStreamForm|Code stage start"
msgstr "" msgstr ""
msgid "CreateValueStreamForm|Create Value Stream"
msgstr ""
msgid "CreateValueStreamForm|Create from default template" msgid "CreateValueStreamForm|Create from default template"
msgstr "" msgstr ""
msgid "CreateValueStreamForm|Create from no template" msgid "CreateValueStreamForm|Create from no template"
msgstr "" msgstr ""
msgid "CreateValueStreamForm|Create new Value Stream"
msgstr ""
msgid "CreateValueStreamForm|Default stages" msgid "CreateValueStreamForm|Default stages"
msgstr "" msgstr ""
msgid "CreateValueStreamForm|Edit Value Stream"
msgstr ""
msgid "CreateValueStreamForm|Editing stage" msgid "CreateValueStreamForm|Editing stage"
msgstr "" msgstr ""
...@@ -8708,6 +8717,9 @@ msgstr "" ...@@ -8708,6 +8717,9 @@ msgstr ""
msgid "CreateValueStreamForm|Restore stage" msgid "CreateValueStreamForm|Restore stage"
msgstr "" msgstr ""
msgid "CreateValueStreamForm|Save Value Stream"
msgstr ""
msgid "CreateValueStreamForm|Select end event" msgid "CreateValueStreamForm|Select end event"
msgstr "" msgstr ""
...@@ -9711,6 +9723,15 @@ msgstr "" ...@@ -9711,6 +9723,15 @@ msgstr ""
msgid "DeleteProject|Failed to restore wiki repository. Please contact the administrator." msgid "DeleteProject|Failed to restore wiki repository. Please contact the administrator."
msgstr "" msgstr ""
msgid "DeleteValueStream|'%{name}' Value Stream deleted"
msgstr ""
msgid "DeleteValueStream|Are you sure you want to delete \"%{name}\" Value Stream?"
msgstr ""
msgid "DeleteValueStream|Delete %{name}"
msgstr ""
msgid "Deleted" msgid "Deleted"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment