Commit e366a50c authored by Ezekiel Kigbo's avatar Ezekiel Kigbo Committed by Brandon Labuschagne

Move custom stage form fields to a separate component

Extracts the custom stage form fields
into a separate component

Adds the `add stage button`

Allows addition of additional custom
stages to a value stream in the form
parent 69766632
...@@ -13,9 +13,9 @@ export const I18N = { ...@@ -13,9 +13,9 @@ export const I18N = {
RECOVER_STAGES_VISIBLE: s__('CreateValueStreamForm|All default stages are currently visible'), RECOVER_STAGES_VISIBLE: s__('CreateValueStreamForm|All default stages are currently visible'),
SELECT_START_EVENT: s__('CreateValueStreamForm|Select start event'), SELECT_START_EVENT: s__('CreateValueStreamForm|Select start event'),
SELECT_END_EVENT: s__('CreateValueStreamForm|Select end event'), SELECT_END_EVENT: s__('CreateValueStreamForm|Select end event'),
FORM_FIELD_NAME_LABEL: s__('CreateValueStreamForm|Name'), FORM_FIELD_NAME_LABEL: s__('CreateValueStreamForm|Value Stream name'),
FORM_FIELD_NAME_PLACEHOLDER: s__('CreateValueStreamForm|Enter a name for the stage'), FORM_FIELD_NAME_PLACEHOLDER: s__('CreateValueStreamForm|Enter value stream name'),
FIELD_STAGE_NAME_PLACEHOLDER: s__('CreateValueStreamForm|Enter stage name'), FORM_FIELD_STAGE_NAME_PLACEHOLDER: s__('CreateValueStreamForm|Enter stage name'),
FORM_FIELD_START_EVENT: s__('CreateValueStreamForm|Start event'), FORM_FIELD_START_EVENT: s__('CreateValueStreamForm|Start event'),
FORM_FIELD_START_EVENT_LABEL: s__('CreateValueStreamForm|Start event label'), FORM_FIELD_START_EVENT_LABEL: s__('CreateValueStreamForm|Start event label'),
FORM_FIELD_END_EVENT: s__('CreateValueStreamForm|End event'), FORM_FIELD_END_EVENT: s__('CreateValueStreamForm|End event'),
...@@ -24,19 +24,24 @@ export const I18N = { ...@@ -24,19 +24,24 @@ export const I18N = {
DEFAULT_FIELD_END_EVENT_LABEL: s__('CreateValueStreamForm|End event: '), DEFAULT_FIELD_END_EVENT_LABEL: s__('CreateValueStreamForm|End event: '),
BTN_UPDATE_STAGE: s__('CreateValueStreamForm|Update stage'), BTN_UPDATE_STAGE: s__('CreateValueStreamForm|Update stage'),
BTN_ADD_STAGE: s__('CreateValueStreamForm|Add stage'), BTN_ADD_STAGE: s__('CreateValueStreamForm|Add stage'),
BTN_ADD_ANOTHER_STAGE: s__('CreateValueStreamForm|Add another stage'),
TITLE_EDIT_STAGE: s__('CreateValueStreamForm|Editing stage'), TITLE_EDIT_STAGE: s__('CreateValueStreamForm|Editing stage'),
TITLE_ADD_STAGE: s__('CreateValueStreamForm|New stage'), TITLE_ADD_STAGE: s__('CreateValueStreamForm|New stage'),
BTN_CANCEL: __('Cancel'), BTN_CANCEL: __('Cancel'),
STAGE_INDEX: s__('CreateValueStreamForm|Stage %{index}'), STAGE_INDEX: s__('CreateValueStreamForm|Stage %{index}'),
HIDDEN_DEFAULT_STAGE: s__('CreateValueStreamForm|%{name} (default)'), HIDDEN_DEFAULT_STAGE: s__('CreateValueStreamForm|%{name} (default)'),
TEMPLATE_DEFAULT: s__('CreateValueStreamForm|Create from default template'),
TEMPLATE_BLANK: s__('CreateValueStreamForm|Create from no template'),
}; };
export const ERRORS = { export const ERRORS = {
MIN_LENGTH: s__('CreateValueStreamForm|Name is required'), VALUE_STREAM_NAME_MIN_LENGTH: s__('CreateValueStreamForm|Name is required'),
STAGE_NAME_MIN_LENGTH: s__('CreateValueStreamForm|Stage name is required'),
MAX_LENGTH: sprintf(s__('CreateValueStreamForm|Maximum length %{maxLength} characters'), { MAX_LENGTH: sprintf(s__('CreateValueStreamForm|Maximum length %{maxLength} characters'), {
maxLength: NAME_MAX_LENGTH, maxLength: NAME_MAX_LENGTH,
}), }),
START_EVENT_REQUIRED: s__('CreateValueStreamForm|Please select a start event first'), START_EVENT_REQUIRED: s__('CreateValueStreamForm|Please select a start event first'),
END_EVENT_REQUIRED: s__('CreateValueStreamForm|Please select an end event'),
STAGE_NAME_EXISTS: s__('CreateValueStreamForm|Stage name already exists'), STAGE_NAME_EXISTS: s__('CreateValueStreamForm|Stage name already exists'),
INVALID_EVENT_PAIRS: s__( INVALID_EVENT_PAIRS: s__(
'CreateValueStreamForm|Start event changed, please select a valid end event', 'CreateValueStreamForm|Start event changed, please select a valid end event',
...@@ -68,6 +73,12 @@ export const defaultFields = { ...@@ -68,6 +73,12 @@ export const defaultFields = {
const defaultStageCommonFields = { custom: false, hidden: false }; const defaultStageCommonFields = { custom: false, hidden: false };
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
* This is a stopgap solution and we should eventually provide these from an API endpoint * This is a stopgap solution and we should eventually provide these from an API endpoint
...@@ -112,3 +123,15 @@ export const DEFAULT_STAGE_CONFIG = BASE_DEFAULT_STAGE_CONFIG.map(({ id, ...rest ...@@ -112,3 +123,15 @@ export const DEFAULT_STAGE_CONFIG = BASE_DEFAULT_STAGE_CONFIG.map(({ id, ...rest
...defaultStageCommonFields, ...defaultStageCommonFields,
name: capitalizeFirstCharacter(id), name: capitalizeFirstCharacter(id),
})); }));
export const PRESET_OPTIONS_DEFAULT = 'default';
export const PRESET_OPTIONS = [
{
text: I18N.TEMPLATE_DEFAULT,
value: PRESET_OPTIONS_DEFAULT,
},
{
text: I18N.TEMPLATE_BLANK,
value: 'blank',
},
];
<script> <script>
import { GlFormGroup, GlFormInput, GlFormSelect } from '@gitlab/ui'; import { GlFormGroup, GlFormInput, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import StageFieldActions from './stage_field_actions.vue';
import LabelsSelector from '../labels_selector.vue'; import LabelsSelector from '../labels_selector.vue';
import { I18N } from './constants'; import { I18N } from './constants';
import { startEventOptions, endEventOptions } from './utils'; import { startEventOptions, endEventOptions } from './utils';
import { isLabelEvent } from '../../utils'; import { isLabelEvent, getLabelEventsIdentifiers } from '../../utils';
export default { export default {
name: 'CustomStageFormFields', name: 'CustomStageFormFields',
components: { components: {
GlFormGroup, GlFormGroup,
GlFormInput, GlFormInput,
GlFormSelect, GlDropdown,
GlDropdownItem,
LabelsSelector, LabelsSelector,
StageFieldActions,
}, },
props: { props: {
fields: { index: {
type: Number,
required: true,
},
totalStages: {
type: Number,
required: true,
},
stage: {
type: Object, type: Object,
required: true, required: true,
}, },
...@@ -23,44 +34,55 @@ export default { ...@@ -23,44 +34,55 @@ export default {
required: false, required: false,
default: () => {}, default: () => {},
}, },
events: { stageEvents: {
type: Array, type: Array,
required: true, required: true,
}, },
labelEvents: {
type: Array,
required: true,
}, },
data() {
return {
labelEvents: getLabelEventsIdentifiers(this.stageEvents),
};
}, },
computed: { computed: {
startEvents() { startEvents() {
return startEventOptions(this.events); return startEventOptions(this.stageEvents);
}, },
endEvents() { endEvents() {
return endEventOptions(this.events, this.fields.startEventIdentifier); return endEventOptions(this.stageEvents, this.stage.startEventIdentifier);
}, },
hasStartEvent() { hasStartEvent() {
return this.fields.startEventIdentifier; return this.stage.startEventIdentifier;
}, },
startEventRequiresLabel() { startEventRequiresLabel() {
return isLabelEvent(this.labelEvents, this.fields.startEventIdentifier); return isLabelEvent(this.labelEvents, this.stage.startEventIdentifier);
}, },
endEventRequiresLabel() { endEventRequiresLabel() {
return isLabelEvent(this.labelEvents, this.fields.endEventIdentifier); return isLabelEvent(this.labelEvents, this.stage.endEventIdentifier);
}, },
hasMultipleStages() {
return this.totalStages > 1;
},
selectedStartEventName() {
return this.eventName(this.stage.startEventIdentifier, 'SELECT_START_EVENT');
},
selectedEndEventName() {
return this.eventName(this.stage.endEventIdentifier, 'SELECT_END_EVENT');
}, },
methods: {
eventFieldClasses(condition) {
return condition ? 'gl-w-half gl-mr-2' : 'gl-w-full';
}, },
methods: {
hasFieldErrors(key) { hasFieldErrors(key) {
return this.errors[key]?.length < 1; return !Object.keys(this.errors).length || this.errors[key]?.length < 1;
}, },
fieldErrorMessage(key) { fieldErrorMessage(key) {
return this.errors[key]?.join('\n'); return this.errors[key]?.join('\n');
}, },
handleUpdateField(field, value) { eventNameByIdentifier(identifier) {
this.$emit('update', field, value); const ev = this.stageEvents.find((e) => e.identifier === identifier);
return ev?.name || null;
},
eventName(eventId, textKey) {
return eventId ? this.eventNameByIdentifier(eventId) : this.$options.I18N[textKey];
}, },
}, },
I18N, I18N,
...@@ -68,91 +90,108 @@ export default { ...@@ -68,91 +90,108 @@ export default {
</script> </script>
<template> <template>
<div> <div>
<div class="gl-display-flex">
<gl-form-group <gl-form-group
:label="$options.I18N.FORM_FIELD_NAME_LABEL" class="gl-flex-grow-1"
label-for="custom-stage-name"
:state="hasFieldErrors('name')" :state="hasFieldErrors('name')"
:invalid-feedback="fieldErrorMessage('name')" :invalid-feedback="fieldErrorMessage('name')"
data-testid="custom-stage-name" :data-testid="`custom-stage-name-${index}`"
> >
<gl-form-input <gl-form-input
:value="fields.name" v-model.trim="stage.name"
class="form-control" :name="`custom-stage-name-${index}`"
type="text" :placeholder="$options.I18N.FORM_FIELD_STAGE_NAME_PLACEHOLDER"
name="custom-stage-name"
:placeholder="$options.I18N.FORM_FIELD_NAME_PLACEHOLDER"
required required
@input="handleUpdateField('name', $event)" @input="$emit('input', { field: 'name', value: $event })"
/> />
</gl-form-group> </gl-form-group>
<div class="d-flex" :class="{ 'gl-justify-content-between': startEventRequiresLabel }"> <stage-field-actions
<div :class="eventFieldClasses(startEventRequiresLabel)"> v-if="hasMultipleStages"
:index="index"
:stage-count="totalStages"
:can-remove="true"
@move="$emit('move', $event)"
@remove="$emit('remove', $event)"
/>
</div>
<div class="gl-display-flex gl-justify-content-between">
<gl-form-group <gl-form-group
data-testid="custom-stage-start-event" :data-testid="`custom-stage-start-event-${index}`"
class="gl-w-half gl-mr-2"
:label="$options.I18N.FORM_FIELD_START_EVENT" :label="$options.I18N.FORM_FIELD_START_EVENT"
label-for="custom-stage-start-event"
:state="hasFieldErrors('startEventIdentifier')" :state="hasFieldErrors('startEventIdentifier')"
:invalid-feedback="fieldErrorMessage('startEventIdentifier')" :invalid-feedback="fieldErrorMessage('startEventIdentifier')"
> >
<gl-form-select <gl-dropdown
v-model="fields.startEventIdentifier" toggle-class="gl-mb-0"
name="custom-stage-start-event" :text="selectedStartEventName"
:required="true" :name="`custom-stage-start-id-${index}`"
:options="startEvents" menu-class="gl-overflow-hidden!"
@input="handleUpdateField('startEventIdentifier', $event)" block
/> >
<gl-dropdown-item
v-for="{ text, value } in startEvents"
:key="`start-event-${value}`"
:value="value"
@click="$emit('input', { field: 'startEventIdentifier', value })"
>{{ text }}</gl-dropdown-item
>
</gl-dropdown>
</gl-form-group> </gl-form-group>
</div>
<div v-if="startEventRequiresLabel" class="gl-w-half gl-ml-2">
<gl-form-group <gl-form-group
data-testid="custom-stage-start-event-label" v-if="startEventRequiresLabel"
class="gl-w-half gl-ml-2"
:data-testid="`custom-stage-start-event-label-${index}`"
:label="$options.I18N.FORM_FIELD_START_EVENT_LABEL" :label="$options.I18N.FORM_FIELD_START_EVENT_LABEL"
label-for="custom-stage-start-event-label"
:state="hasFieldErrors('startEventLabelId')" :state="hasFieldErrors('startEventLabelId')"
:invalid-feedback="fieldErrorMessage('startEventLabelId')" :invalid-feedback="fieldErrorMessage('startEventLabelId')"
> >
<labels-selector <labels-selector
:selected-label-id="[fields.startEventLabelId]" :selected-label-id="[stage.startEventLabelId]"
name="custom-stage-start-event-label" :name="`custom-stage-start-label-${index}`"
@selectLabel="handleUpdateField('startEventLabelId', $event)" @select-label="$emit('input', { field: 'startEventLabelId', value: $event })"
/> />
</gl-form-group> </gl-form-group>
</div> </div>
</div> <div class="gl-display-flex gl-justify-content-between">
<div class="d-flex" :class="{ 'gl-justify-content-between': endEventRequiresLabel }">
<div :class="eventFieldClasses(endEventRequiresLabel)">
<gl-form-group <gl-form-group
data-testid="custom-stage-end-event" :data-testid="`custom-stage-end-event-${index}`"
class="gl-w-half gl-mr-2"
:label="$options.I18N.FORM_FIELD_END_EVENT" :label="$options.I18N.FORM_FIELD_END_EVENT"
label-for="custom-stage-end-event"
:state="hasFieldErrors('endEventIdentifier')" :state="hasFieldErrors('endEventIdentifier')"
:invalid-feedback="fieldErrorMessage('endEventIdentifier')" :invalid-feedback="fieldErrorMessage('endEventIdentifier')"
> >
<gl-form-select <gl-dropdown
v-model="fields.endEventIdentifier" toggle-class="gl-mb-0"
name="custom-stage-end-event" :text="selectedEndEventName"
:options="endEvents" :name="`custom-stage-end-id-${index}`"
:required="true"
:disabled="!hasStartEvent" :disabled="!hasStartEvent"
@input="handleUpdateField('endEventIdentifier', $event)" menu-class="gl-overflow-hidden!"
/> block
>
<gl-dropdown-item
v-for="{ text, value } in endEvents"
:key="`end-event-${value}`"
:value="value"
@click="$emit('input', { field: 'endEventIdentifier', value })"
>{{ text }}</gl-dropdown-item
>
</gl-dropdown>
</gl-form-group> </gl-form-group>
</div>
<div v-if="endEventRequiresLabel" class="gl-w-half gl-ml-2">
<gl-form-group <gl-form-group
data-testid="custom-stage-end-event-label" v-if="endEventRequiresLabel"
class="gl-w-half gl-ml-2"
:data-testid="`custom-stage-end-event-label-${index}`"
:label="$options.I18N.FORM_FIELD_END_EVENT_LABEL" :label="$options.I18N.FORM_FIELD_END_EVENT_LABEL"
label-for="custom-stage-end-event-label"
:state="hasFieldErrors('endEventLabelId')" :state="hasFieldErrors('endEventLabelId')"
:invalid-feedback="fieldErrorMessage('endEventLabelId')" :invalid-feedback="fieldErrorMessage('endEventLabelId')"
> >
<labels-selector <labels-selector
:selected-label-id="[fields.endEventLabelId]" :selected-label-id="[stage.endEventLabelId]"
name="custom-stage-end-event-label" :name="`custom-stage-end-label-${index}`"
@selectLabel="handleUpdateField('endEventLabelId', $event)" @select-label="$emit('input', { field: 'endEventLabelId', value: $event })"
/> />
</gl-form-group> </gl-form-group>
</div> </div>
</div> </div>
</div>
</template> </template>
...@@ -72,7 +72,7 @@ export default { ...@@ -72,7 +72,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.FIELD_STAGE_NAME_PLACEHOLDER" :placeholder="$options.I18N.FORM_FIELD_STAGE_NAME_PLACEHOLDER"
required required
@input="$emit('input', $event)" @input="$emit('input', $event)"
/> />
...@@ -84,21 +84,23 @@ export default { ...@@ -84,21 +84,23 @@ export default {
@hide="$emit('hide', $event)" @hide="$emit('hide', $event)"
/> />
</div> </div>
<div class="gl-display-flex" :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>{{ 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" <gl-form-text v-if="stage.startEventLabel" class="gl-m-0"
>&nbsp;-&nbsp;{{ stage.startEventLabel }}</gl-form-text >&nbsp;-&nbsp;{{ stage.startEventLabel }}</gl-form-text
> >
</div> </div>
<div class="gl-display-flex" :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>{{ 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">&nbsp;-&nbsp;{{ stage.endEventLabel }}</gl-form-text> <gl-form-text v-if="stage.endEventLabel" class="gl-m-0"
>&nbsp;-&nbsp;{{ stage.endEventLabel }}</gl-form-text
>
</div> </div>
</div> </div>
</template> </template>
...@@ -17,6 +17,11 @@ export default { ...@@ -17,6 +17,11 @@ export default {
type: Number, type: Number,
required: true, required: true,
}, },
canRemove: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
lastStageIndex() { lastStageIndex() {
...@@ -28,6 +33,15 @@ export default { ...@@ -28,6 +33,15 @@ export default {
isLastActiveStage() { isLastActiveStage() {
return this.index === this.lastStageIndex; return this.index === this.lastStageIndex;
}, },
hideActionEvent() {
return this.canRemove ? 'remove' : 'hide';
},
hideActionIcon() {
return this.canRemove ? 'remove' : 'archive';
},
hideActionTestId() {
return `stage-action-${this.canRemove ? 'remove' : 'hide'}-${this.index}`;
},
}, },
STAGE_SORT_DIRECTION, STAGE_SORT_DIRECTION,
}; };
...@@ -49,9 +63,9 @@ export default { ...@@ -49,9 +63,9 @@ export default {
/> />
</gl-button-group> </gl-button-group>
<gl-button <gl-button
:data-testid="`stage-action-hide-${index}`" :data-testid="hideActionTestId"
icon="archive" :icon="hideActionIcon"
@click="$emit('hide', index)" @click="$emit(hideActionEvent, index)"
/> />
</div> </div>
</template> </template>
...@@ -95,17 +95,17 @@ export const validateStage = (fields) => { ...@@ -95,17 +95,17 @@ export const validateStage = (fields) => {
: []; : [];
} }
} else { } else {
newErrors.name = [ERRORS.MIN_LENGTH]; newErrors.name = [ERRORS.STAGE_NAME_MIN_LENGTH];
} }
if (fields?.startEventIdentifier) { if (fields?.startEventIdentifier) {
if (fields?.endEventIdentifier) {
newErrors.endEventIdentifier = []; newErrors.endEventIdentifier = [];
} else { } else {
newErrors.endEventIdentifier = [ERRORS.START_EVENT_REQUIRED]; newErrors.endEventIdentifier = [ERRORS.END_EVENT_REQUIRED];
} }
} else {
if (fields?.startEventIdentifier && fields?.endEventIdentifier) { newErrors.endEventIdentifier = [ERRORS.START_EVENT_REQUIRED];
newErrors.endEventIdentifier = [];
} }
return newErrors; return newErrors;
}; };
...@@ -123,7 +123,7 @@ export const validateValueStreamName = ({ name = '' }) => { ...@@ -123,7 +123,7 @@ export const validateValueStreamName = ({ name = '' }) => {
errors.name.push(ERRORS.MAX_LENGTH); errors.name.push(ERRORS.MAX_LENGTH);
} }
if (!name.length) { if (!name.length) {
errors.name.push(ERRORS.MIN_LENGTH); errors.name.push(ERRORS.VALUE_STREAM_NAME_MIN_LENGTH);
} }
return errors; return errors;
}; };
...@@ -155,7 +155,7 @@ export default { ...@@ -155,7 +155,7 @@ export default {
handleRecoverStage(id) { handleRecoverStage(id) {
this.$emit(STAGE_ACTIONS.UPDATE, { id, hidden: false }); this.$emit(STAGE_ACTIONS.UPDATE, { id, hidden: false });
}, },
handleUpdateFields(field, value) { handleUpdateFields({ field, value }) {
this.fields = { ...this.fields, [field]: value }; this.fields = { ...this.fields, [field]: value };
const newErrors = validateStage({ ...this.fields, custom: true }); const newErrors = validateStage({ ...this.fields, custom: true });
...@@ -196,11 +196,13 @@ export default { ...@@ -196,11 +196,13 @@ export default {
</gl-dropdown> </gl-dropdown>
</div> </div>
<custom-stage-form-fields <custom-stage-form-fields
:fields="fields" :index="0"
:label-events="labelEvents" :total-stages="1"
:stage="fields"
:errors="errors" :errors="errors"
:events="events" :stage-events="events"
@update="handleUpdateFields" @input="handleUpdateFields"
@select-label="({ field, value }) => handleUpdateFields({ field, value })"
/> />
<div> <div>
<gl-button <gl-button
......
...@@ -159,7 +159,7 @@ export default { ...@@ -159,7 +159,7 @@ export default {
'cursor-not-allowed': disabled, 'cursor-not-allowed': disabled,
}" }"
:active="isSelectedLabel(label.id)" :active="isSelectedLabel(label.id)"
@click.prevent="$emit('selectLabel', label.id, selectedLabelIds)" @click.prevent="$emit('select-label', label.id, selectedLabelIds)"
> >
<gl-icon <gl-icon
v-if="multiselect && isSelectedLabel(label.id)" v-if="multiselect && isSelectedLabel(label.id)"
......
<script> <script>
import Vue from 'vue'; import Vue from 'vue';
import { GlButton, GlForm, GlFormInput, GlFormGroup, GlModal } from '@gitlab/ui'; import { GlButton, GlForm, GlFormInput, GlFormGroup, GlFormRadioGroup, GlModal } from '@gitlab/ui';
import { debounce } from 'lodash';
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { sprintf } from '~/locale'; import { sprintf } from '~/locale';
import { import {
DEFAULT_STAGE_CONFIG, DEFAULT_STAGE_CONFIG,
STAGE_SORT_DIRECTION, STAGE_SORT_DIRECTION,
I18N, I18N,
defaultCustomStageFields,
PRESET_OPTIONS,
PRESET_OPTIONS_DEFAULT,
} from './create_value_stream_form/constants'; } from './create_value_stream_form/constants';
import { validateValueStreamName, validateStage } from './create_value_stream_form/utils'; import { validateValueStreamName, validateStage } from './create_value_stream_form/utils';
import DefaultStageFields from './create_value_stream_form/default_stage_fields.vue'; import DefaultStageFields from './create_value_stream_form/default_stage_fields.vue';
import { DATA_REFETCH_DELAY } from '../../shared/constants'; import CustomStageFields from './create_value_stream_form/custom_stage_fields.vue';
const swapArrayItems = (arr, left, right) => [ const swapArrayItems = (arr, left, right) => [
...arr.slice(0, left), ...arr.slice(0, left),
...@@ -23,6 +25,9 @@ const swapArrayItems = (arr, left, right) => [ ...@@ -23,6 +25,9 @@ const swapArrayItems = (arr, left, right) => [
const findStageIndexByName = (stages, target = '') => const findStageIndexByName = (stages, target = '') =>
stages.findIndex(({ name }) => name === target); stages.findIndex(({ name }) => name === target);
const initializeStageErrors = (selectedPreset = PRESET_OPTIONS_DEFAULT) =>
selectedPreset === PRESET_OPTIONS_DEFAULT ? DEFAULT_STAGE_CONFIG.map(() => ({})) : [{}];
export default { export default {
name: 'ValueStreamForm', name: 'ValueStreamForm',
components: { components: {
...@@ -30,8 +35,10 @@ export default { ...@@ -30,8 +35,10 @@ export default {
GlForm, GlForm,
GlFormInput, GlFormInput,
GlFormGroup, GlFormGroup,
GlFormRadioGroup,
GlModal, GlModal,
DefaultStageFields, DefaultStageFields,
CustomStageFields,
}, },
props: { props: {
initialData: { initialData: {
...@@ -50,13 +57,16 @@ export default { ...@@ -50,13 +57,16 @@ export default {
const additionalFields = hasExtendedFormFields const additionalFields = hasExtendedFormFields
? { ? {
stages: DEFAULT_STAGE_CONFIG, stages: DEFAULT_STAGE_CONFIG,
stageErrors: initializeStageErrors(PRESET_OPTIONS_DEFAULT),
...initialData, ...initialData,
} }
: { stages: [] }; : { stages: [] };
return { return {
selectedPreset: PRESET_OPTIONS[0].value,
presetOptions: PRESET_OPTIONS,
name: '', name: '',
nameError: {}, nameError: { name: [] },
stageErrors: [], stageErrors: [{}],
...additionalFields, ...additionalFields,
}; };
}, },
...@@ -92,6 +102,16 @@ export default { ...@@ -92,6 +102,16 @@ export default {
], ],
}; };
}, },
secondaryProps() {
return {
text: this.$options.I18N.BTN_ADD_ANOTHER_STAGE,
attributes: [
{ category: 'secondary' },
{ variant: 'info' },
{ class: this.hasExtendedFormFields ? '' : 'gl-display-none' },
],
};
},
hiddenStages() { hiddenStages() {
return this.stages.filter((stage) => stage.hidden); return this.stages.filter((stage) => stage.hidden);
}, },
...@@ -108,16 +128,10 @@ export default { ...@@ -108,16 +128,10 @@ export default {
const { initialFormErrors } = this; const { initialFormErrors } = this;
if (this.hasInitialFormErrors) { if (this.hasInitialFormErrors) {
this.stageErrors = initialFormErrors; this.stageErrors = initialFormErrors;
} else {
this.validate();
} }
}, },
methods: { methods: {
...mapActions(['createValueStream']), ...mapActions(['createValueStream']),
onUpdateValueStreamName: debounce(function debouncedValidation() {
const { name } = this;
this.nameError = validateValueStreamName({ name });
}, DATA_REFETCH_DELAY),
onSubmit() { onSubmit() {
const { name, stages } = this; const { name, stages } = this;
return this.createValueStream({ return this.createValueStream({
...@@ -136,6 +150,11 @@ export default { ...@@ -136,6 +150,11 @@ export default {
} }
}); });
}, },
stageKey(index) {
return this.selectedPreset === PRESET_OPTIONS_DEFAULT
? `default-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 });
}, },
...@@ -147,15 +166,18 @@ export default { ...@@ -147,15 +166,18 @@ export default {
}, },
validate() { validate() {
const { name } = this; const { name } = this;
this.nameError = validateValueStreamName({ name }); Vue.set(this, 'nameError', validateValueStreamName({ name }));
this.stageErrors = this.validateStages(); Vue.set(this, 'stageErrors', this.validateStages());
},
moveItem(arr, index, direction) {
return direction === STAGE_SORT_DIRECTION.UP
? swapArrayItems(arr, index - 1, index)
: swapArrayItems(arr, index, index + 1);
}, },
handleMove({ index, direction }) { handleMove({ index, direction }) {
const newStages = const newStages = this.moveItem(this.stages, index, direction);
direction === STAGE_SORT_DIRECTION.UP const newErrors = this.moveItem(this.stageErrors, index, direction);
? swapArrayItems(this.stages, index - 1, index) Vue.set(this, 'stageErrors', newErrors);
: swapArrayItems(this.stages, index, index + 1);
Vue.set(this, 'stages', newStages); Vue.set(this, 'stages', newStages);
}, },
validateStageFields(index) { validateStageFields(index) {
...@@ -168,17 +190,45 @@ export default { ...@@ -168,17 +190,45 @@ export default {
const stage = this.stages[index]; const stage = this.stages[index];
Vue.set(this.stages, index, { ...stage, hidden: true }); Vue.set(this.stages, index, { ...stage, hidden: true });
}, },
onRemove(index) {
const newErrors = this.stageErrors.filter((_, idx) => idx !== index);
const newStages = this.stages.filter((_, idx) => idx !== index);
Vue.set(this, 'stages', [...newStages]);
Vue.set(this, 'stageErrors', [...newErrors]);
},
onRestore(hiddenStageIndex) { onRestore(hiddenStageIndex) {
const stage = this.hiddenStages[hiddenStageIndex]; const stage = this.hiddenStages[hiddenStageIndex];
const stageIndex = findStageIndexByName(this.stages, stage.name); const stageIndex = findStageIndexByName(this.stages, stage.name);
Vue.set(this.stages, stageIndex, { ...stage, hidden: false }); Vue.set(this.stages, stageIndex, { ...stage, hidden: false });
}, },
handleReset() { onAddStage() {
// validate previous stages only and add a new stage
this.validate();
Vue.set(this, 'stages', [...this.stages, { ...defaultCustomStageFields }]);
Vue.set(this, 'stageErrors', [...this.stageErrors, {}]);
},
onFieldInput(activeStageIndex, { field, value }) {
const updatedStage = { ...this.stages[activeStageIndex], [field]: value };
Vue.set(this.stages, activeStageIndex, updatedStage);
},
handleResetDefaults() {
this.name = ''; this.name = '';
DEFAULT_STAGE_CONFIG.forEach((stage, index) => { DEFAULT_STAGE_CONFIG.forEach((stage, index) => {
Vue.set(this.stages, index, { ...stage, hidden: false }); Vue.set(this.stages, index, { ...stage, hidden: false });
}); });
}, },
handleResetBlank() {
this.name = '';
Vue.set(this, 'stages', [{ ...defaultCustomStageFields }]);
},
onSelectPreset() {
if (this.selectedPreset === PRESET_OPTIONS_DEFAULT) {
this.handleResetDefaults();
} else {
this.handleResetBlank();
}
Vue.set(this, 'stageErrors', initializeStageErrors(this.selectedPreset));
},
}, },
I18N, I18N,
}; };
...@@ -187,10 +237,13 @@ export default { ...@@ -187,10 +237,13 @@ export default {
<gl-modal <gl-modal
data-testid="value-stream-form-modal" data-testid="value-stream-form-modal"
modal-id="value-stream-form-modal" modal-id="value-stream-form-modal"
dialog-class="gl-align-items-flex-start! gl-py-7"
scrollable scrollable
:title="$options.I18N.FORM_TITLE" :title="$options.I18N.FORM_TITLE"
:action-primary="primaryProps" :action-primary="primaryProps"
:action-secondary="secondaryProps"
:action-cancel="{ text: $options.I18N.BTN_CANCEL }" :action-cancel="{ text: $options.I18N.BTN_CANCEL }"
@secondary.prevent="onAddStage"
@primary.prevent="onSubmit" @primary.prevent="onSubmit"
> >
<gl-form> <gl-form>
...@@ -208,25 +261,45 @@ export default { ...@@ -208,25 +261,45 @@ export default {
:placeholder="$options.I18N.FORM_FIELD_NAME_PLACEHOLDER" :placeholder="$options.I18N.FORM_FIELD_NAME_PLACEHOLDER"
:state="isValueStreamNameValid" :state="isValueStreamNameValid"
required required
@input="onUpdateValueStreamName"
/> />
<gl-button <gl-button
v-if="hiddenStages.length" v-if="hiddenStages.length"
class="gl-ml-3" class="gl-ml-3"
variant="link" variant="link"
@click="handleReset" @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
v-if="hasExtendedFormFields"
v-model="selectedPreset"
class="gl-mb-4"
data-testid="vsa-preset-selector"
:options="presetOptions"
name="preset"
@input="onSelectPreset"
/>
<div v-if="hasExtendedFormFields" data-testid="extended-form-fields"> <div v-if="hasExtendedFormFields" data-testid="extended-form-fields">
<hr /> <div v-for="(stage, activeStageIndex) in activeStages" :key="stageKey(activeStageIndex)">
<div v-for="(stage, activeStageIndex) in activeStages" :key="activeStageIndex"> <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" class="gl-display-flex gl-m-0 gl-vertical-align-middle gl-mr-2 gl-font-weight-bold gl-display-flex gl-pb-3"
>{{ stageGroupLabel(activeStageIndex) }}</span >{{ stageGroupLabel(activeStageIndex) }}</span
> >
<custom-stage-fields
v-if="stage.custom"
:stage="stage"
:stage-events="formEvents"
:index="activeStageIndex"
:total-stages="activeStages.length"
:errors="fieldErrors(activeStageIndex)"
@move="handleMove"
@remove="onRemove"
@input="onFieldInput(activeStageIndex, $event)"
/>
<default-stage-fields <default-stage-fields
v-else
:stage="stage" :stage="stage"
:stage-events="formEvents" :stage-events="formEvents"
:index="activeStageIndex" :index="activeStageIndex"
......
...@@ -31,8 +31,15 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do ...@@ -31,8 +31,15 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do
custom_stage_with_labels_name = 'Cool beans - now with labels' custom_stage_with_labels_name = 'Cool beans - now with labels'
start_event_identifier = :merge_request_created start_event_identifier = :merge_request_created
end_event_identifier = :merge_request_merged end_event_identifier = :merge_request_merged
start_event_text = "Merge request created"
end_event_text = "Merge request merged"
start_label_event = :issue_label_added start_label_event = :issue_label_added
end_label_event = :issue_label_removed end_label_event = :issue_label_removed
start_event_field = 'custom-stage-start-event-0'
end_event_field = 'custom-stage-end-event-0'
start_field_label = 'custom-stage-start-event-label-0'
end_field_label = 'custom-stage-end-event-label-0'
name_field = 'custom-stage-name-0'
let(:add_stage_button) { '.js-add-stage-button' } let(:add_stage_button) { '.js-add-stage-button' }
let(:params) { { name: custom_stage_name, start_event_identifier: start_event_identifier, end_event_identifier: end_event_identifier } } let(:params) { { name: custom_stage_name, start_event_identifier: start_event_identifier, end_event_identifier: end_event_identifier } }
...@@ -57,15 +64,17 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do ...@@ -57,15 +64,17 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do
end end
def select_dropdown_option(name, value = start_event_identifier) def select_dropdown_option(name, value = start_event_identifier)
page.find("[data-testid='#{name}'] select").all('option').find { |item| item.value == value.to_s }.select_option toggle_dropdown name
page.find("[data-testid='#{name}'] .dropdown-menu").all('.dropdown-item').find { |item| item.value == value.to_s }.click
end end
def select_dropdown_option_by_value(name, value, elem = 'option') def select_dropdown_option_by_value(name, value, elem = '.dropdown-item')
page.find("[data-testid='#{name}'] select").find("#{elem}[value=#{value}]").select_option toggle_dropdown name
page.find("[data-testid='#{name}'] .dropdown-menu").find("#{elem}[value='#{value}']").click
end end
def wait_for_labels(field) def toggle_dropdown(field)
page.within("[name=#{field}]") do page.within("[data-testid='#{field}']") do
find('.dropdown-toggle').click find('.dropdown-toggle').click
wait_for_requests wait_for_requests
...@@ -75,7 +84,7 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do ...@@ -75,7 +84,7 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do
end end
def select_dropdown_label(field, index = 1) def select_dropdown_label(field, index = 1)
page.find("[name=#{field}] .dropdown-menu").all('.dropdown-item')[index].click page.find("[data-testid='#{field}'] .dropdown-menu").all('.dropdown-item')[index].click
end end
def drag_from_index_to_index(from, to) def drag_from_index_to_index(from, to)
...@@ -185,7 +194,7 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do ...@@ -185,7 +194,7 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do
shared_examples 'submits custom stage form successfully' do |stage_name| shared_examples 'submits custom stage form successfully' do |stage_name|
it 'custom stage is saved with confirmation message' do it 'custom stage is saved with confirmation message' do
fill_in 'custom-stage-name', with: stage_name fill_in name_field, with: stage_name
click_button(s_('CustomCycleAnalytics|Add stage')) click_button(s_('CustomCycleAnalytics|Add stage'))
expect(page.find('.flash-notice')).to have_text(_("Your custom stage '%{title}' was created") % { title: stage_name }) expect(page.find('.flash-notice')).to have_text(_("Your custom stage '%{title}' was created") % { title: stage_name })
...@@ -210,9 +219,9 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do ...@@ -210,9 +219,9 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do
context 'with all required fields set' do context 'with all required fields set' do
before do before do
fill_in 'custom-stage-name', with: custom_stage_name fill_in name_field, with: custom_stage_name
select_dropdown_option 'custom-stage-start-event', start_event_identifier select_dropdown_option start_event_field, start_event_identifier
select_dropdown_option 'custom-stage-end-event', end_event_identifier select_dropdown_option end_event_field, end_event_identifier
end end
it 'does not have label dropdowns' do it 'does not have label dropdowns' do
...@@ -221,13 +230,13 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do ...@@ -221,13 +230,13 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do
end end
it 'submit button is disabled if a default name is used' do it 'submit button is disabled if a default name is used' do
fill_in 'custom-stage-name', with: 'issue' fill_in name_field, with: 'issue'
expect(page).to have_button(s_('CustomCycleAnalytics|Add stage'), disabled: true) expect(page).to have_button(s_('CustomCycleAnalytics|Add stage'), disabled: true)
end end
it 'submit button is disabled if the start event changes' do it 'submit button is disabled if the start event changes' do
select_dropdown_option 'custom-stage-start-event', 'issue_created' select_dropdown_option start_event_field, 'issue_created'
expect(page).to have_button(s_('CustomCycleAnalytics|Add stage'), disabled: true) expect(page).to have_button(s_('CustomCycleAnalytics|Add stage'), disabled: true)
end end
...@@ -237,9 +246,9 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do ...@@ -237,9 +246,9 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do
context 'with label based stages selected' do context 'with label based stages selected' do
before do before do
fill_in 'custom-stage-name', with: custom_stage_with_labels_name fill_in name_field, with: custom_stage_with_labels_name
select_dropdown_option_by_value 'custom-stage-start-event', start_label_event select_dropdown_option_by_value start_event_field, start_label_event
select_dropdown_option_by_value 'custom-stage-end-event', end_label_event select_dropdown_option_by_value end_event_field, end_label_event
end end
it 'submit button is disabled' do it 'submit button is disabled' do
...@@ -247,12 +256,9 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do ...@@ -247,12 +256,9 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do
end end
context 'with labels available' do context 'with labels available' do
start_field = 'custom-stage-start-event-label'
end_field = 'custom-stage-end-event-label'
it 'does not contain labels from outside the group' do it 'does not contain labels from outside the group' do
wait_for_labels(start_field) toggle_dropdown(start_field_label)
menu = page.find("[data-testid=#{start_field}] .dropdown-menu") menu = page.find("[data-testid=#{start_field_label}] .dropdown-menu")
expect(menu).not_to have_content(other_label.name) expect(menu).not_to have_content(other_label.name)
expect(menu).to have_content(first_label.name) expect(menu).to have_content(first_label.name)
...@@ -261,11 +267,11 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do ...@@ -261,11 +267,11 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do
context 'with all required fields set' do context 'with all required fields set' do
before do before do
wait_for_labels(start_field) toggle_dropdown(start_field_label)
select_dropdown_label start_field, 0 select_dropdown_label start_field_label, 0
wait_for_labels(end_field) toggle_dropdown(end_field_label)
select_dropdown_label end_field, 1 select_dropdown_label end_field_label, 1
end end
include_examples 'submits custom stage form successfully', custom_stage_with_labels_name include_examples 'submits custom stage form successfully', custom_stage_with_labels_name
...@@ -279,9 +285,6 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do ...@@ -279,9 +285,6 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do
context 'Edit stage form' do context 'Edit stage form' do
let(:stage_form_class) { '.custom-stage-form' } let(:stage_form_class) { '.custom-stage-form' }
let(:stage_save_button) { '[data-testid="save-custom-stage"]' } let(:stage_save_button) { '[data-testid="save-custom-stage"]' }
let(:name_field) { 'custom-stage-name' }
let(:start_event_field) { 'custom-stage-start-event' }
let(:end_event_field) { 'custom-stage-end-event' }
let(:updated_custom_stage_name) { 'Extra uber cool stage' } let(:updated_custom_stage_name) { 'Extra uber cool stage' }
before do before do
...@@ -292,9 +295,9 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do ...@@ -292,9 +295,9 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do
context 'with no changes to the data' do context 'with no changes to the data' do
it 'prepopulates the stage data and disables submit button' do it 'prepopulates the stage data and disables submit button' do
expect(page.find(stage_form_class)).to have_text(s_('CustomCycleAnalytics|Editing stage')) expect(page.find(stage_form_class)).to have_text(s_('CustomCycleAnalytics|Editing stage'))
expect(page.find_field(name_field).value).to eq custom_stage_name expect(page.find("[name='#{name_field}']").value).to eq custom_stage_name
expect(page.find_field(start_event_field).value).to eq start_event_identifier.to_s expect(page.find("[data-testid='#{start_event_field}']")).to have_text(start_event_text)
expect(page.find_field(end_event_field).value).to eq end_event_identifier.to_s expect(page.find("[data-testid='#{end_event_field}']")).to have_text(end_event_text)
expect(page.find(stage_save_button)[:disabled]).to eq 'true' expect(page.find(stage_save_button)[:disabled]).to eq 'true'
end end
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlFormSelect, GlFormInput } from '@gitlab/ui'; import { GlDropdown, GlFormInput } from '@gitlab/ui';
import CustomStageFields from 'ee/analytics/cycle_analytics/components/create_value_stream_form/custom_stage_fields.vue'; import CustomStageFields from 'ee/analytics/cycle_analytics/components/create_value_stream_form/custom_stage_fields.vue';
import StageFieldActions from 'ee/analytics/cycle_analytics/components/create_value_stream_form/stage_field_actions.vue';
import LabelsSelector from 'ee/analytics/cycle_analytics/components/labels_selector.vue'; import LabelsSelector from 'ee/analytics/cycle_analytics/components/labels_selector.vue';
import { getLabelEventsIdentifiers } from 'ee/analytics/cycle_analytics/utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { emptyState, emptyErrorsState, firstLabel } from './mock_data'; import { emptyState, emptyErrorsState, firstLabel } from './mock_data';
import { import {
customStageEvents as events, customStageEvents as stageEvents,
customStageLabelEvents,
labelStartEvent, labelStartEvent,
labelStopEvent, labelStopEvent,
customStageStopEvents as endEvents, customStageStopEvents as endEvents,
...@@ -26,47 +26,52 @@ const formatEndEventOpts = (_events) => [ ...@@ -26,47 +26,52 @@ const formatEndEventOpts = (_events) => [
.map(({ name: text, identifier: value }) => ({ text, value })), .map(({ name: text, identifier: value }) => ({ text, value })),
]; ];
const startEventOptions = formatStartEventOpts(events); const startEventOptions = formatStartEventOpts(stageEvents);
const endEventOptions = formatEndEventOpts(events); const endEventOptions = formatEndEventOpts(stageEvents);
describe('CustomStageFields', () => { describe('CustomStageFields', () => {
function createComponent({ function createComponent({
fields = emptyState, stage = emptyState,
errors = emptyErrorsState, errors = emptyErrorsState,
labelEvents = getLabelEventsIdentifiers(customStageLabelEvents),
stubs = {}, stubs = {},
props = {}, props = {},
} = {}) { } = {}) {
return shallowMount(CustomStageFields, { return extendedWrapper(
shallowMount(CustomStageFields, {
propsData: { propsData: {
fields, stage,
errors, errors,
events, stageEvents,
labelEvents, index: 0,
totalStages: 3,
...props, ...props,
}, },
stubs: { stubs: {
'labels-selector': false, 'labels-selector': false,
...stubs, ...stubs,
}, },
}); }),
);
} }
let wrapper = null; let wrapper = null;
const getSelectField = (dropdownEl) => dropdownEl.find(GlFormSelect); const getDropdown = (dropdownEl) => dropdownEl.find(GlDropdown);
const getLabelSelect = (dropdownEl) => dropdownEl.find(LabelsSelector); const getLabelSelect = (dropdownEl) => dropdownEl.find(LabelsSelector);
const findName = () => wrapper.find('[data-testid="custom-stage-name"]'); const findName = (index = 0) => wrapper.findByTestId(`custom-stage-name-${index}`);
const findStartEvent = () => wrapper.find('[data-testid="custom-stage-start-event"]'); const findStartEvent = (index = 0) => wrapper.findByTestId(`custom-stage-start-event-${index}`);
const findEndEvent = () => wrapper.find('[data-testid="custom-stage-end-event"]'); const findEndEvent = (index = 0) => wrapper.findByTestId(`custom-stage-end-event-${index}`);
const findStartEventLabel = () => wrapper.find('[data-testid="custom-stage-start-event-label"]'); const findStartEventLabel = (index = 0) =>
const findEndEventLabel = () => wrapper.find('[data-testid="custom-stage-end-event-label"]'); wrapper.findByTestId(`custom-stage-start-event-label-${index}`);
const findEndEventLabel = (index = 0) =>
wrapper.findByTestId(`custom-stage-end-event-label-${index}`);
const findNameField = () => findName().find(GlFormInput); const findNameField = () => findName().find(GlFormInput);
const findStartEventField = () => getSelectField(findStartEvent()); const findStartEventField = () => getDropdown(findStartEvent());
const findEndEventField = () => getSelectField(findEndEvent()); const findEndEventField = () => getDropdown(findEndEvent());
const findStartEventLabelField = () => getLabelSelect(findStartEventLabel()); const findStartEventLabelField = () => getLabelSelect(findStartEventLabel());
const findEndEventLabelField = () => getLabelSelect(findEndEventLabel()); const findEndEventLabelField = () => getLabelSelect(findEndEventLabel());
const findStageFieldActions = () => wrapper.find(StageFieldActions);
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); wrapper = createComponent();
...@@ -94,16 +99,16 @@ describe('CustomStageFields', () => { ...@@ -94,16 +99,16 @@ describe('CustomStageFields', () => {
['End event label', findEndEventLabel], ['End event label', findEndEventLabel],
])('Default state', (field, finder) => { ])('Default state', (field, finder) => {
it(`field '${field}' is hidden by default`, () => { it(`field '${field}' is hidden by default`, () => {
expect(finder(wrapper).exists()).toBe(false); expect(finder().exists()).toBe(false);
}); });
}); });
describe('Fields', () => { describe('Fields', () => {
it('emit update event when a field is changed', () => { it('emit input event when a field is changed', () => {
expect(wrapper.emitted('update')).toBeUndefined(); expect(wrapper.emitted('input')).toBeUndefined();
findNameField(wrapper).vm.$emit('input', 'Cool new stage'); findNameField().vm.$emit('input', 'Cool new stage');
expect(wrapper.emitted('update')[0]).toEqual(['name', 'Cool new stage']); expect(wrapper.emitted('input')[0]).toEqual([{ field: 'name', value: 'Cool new stage' }]);
}); });
}); });
...@@ -123,22 +128,24 @@ describe('CustomStageFields', () => { ...@@ -123,22 +128,24 @@ describe('CustomStageFields', () => {
describe('start event label', () => { describe('start event label', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ wrapper = createComponent({
fields: { stage: {
startEventIdentifier: labelStartEvent.identifier, startEventIdentifier: labelStartEvent.identifier,
}, },
}); });
}); });
it('will display the start event label field if a label event is selected', () => { it('will display the start event label field if a label event is selected', () => {
expect(findStartEventLabel(wrapper).exists()).toEqual(true); expect(findStartEventLabel().exists()).toEqual(true);
}); });
it('will emit the `update` event when the start event label field when selected', async () => { it('will emit the `input` event when the start event label field when selected', async () => {
expect(wrapper.emitted().update).toBeUndefined(); expect(wrapper.emitted('input')).toBeUndefined();
findStartEventLabelField(wrapper).vm.$emit('selectLabel', firstLabel.id); findStartEventLabelField().vm.$emit('select-label', firstLabel.id);
expect(wrapper.emitted().update[0]).toEqual(['startEventLabelId', firstLabel.id]); expect(wrapper.emitted('input')[0]).toEqual([
{ field: 'startEventLabelId', value: firstLabel.id },
]);
}); });
}); });
}); });
...@@ -165,7 +172,7 @@ describe('CustomStageFields', () => { ...@@ -165,7 +172,7 @@ describe('CustomStageFields', () => {
describe('end event label', () => { describe('end event label', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ wrapper = createComponent({
fields: { stage: {
startEventIdentifier: labelStartEvent.identifier, startEventIdentifier: labelStartEvent.identifier,
endEventIdentifier: labelStopEvent.identifier, endEventIdentifier: labelStopEvent.identifier,
}, },
...@@ -173,15 +180,33 @@ describe('CustomStageFields', () => { ...@@ -173,15 +180,33 @@ describe('CustomStageFields', () => {
}); });
it('will display the end event label field if a label event is selected', () => { it('will display the end event label field if a label event is selected', () => {
expect(findEndEventLabel(wrapper).exists()).toEqual(true); expect(findEndEventLabel().exists()).toEqual(true);
}); });
it('will emit the `update` event when the start event label field when selected', async () => { it('will emit the `input` event when the start event label field when selected', async () => {
expect(wrapper.emitted().update).toBeUndefined(); expect(wrapper.emitted('input')).toBeUndefined();
findEndEventLabelField().vm.$emit('select-label', firstLabel.id);
findEndEventLabelField(wrapper).vm.$emit('selectLabel', firstLabel.id); expect(wrapper.emitted('input')[0]).toEqual([
{ field: 'endEventLabelId', value: firstLabel.id },
]);
});
});
});
describe('Stage actions', () => {
it('will display the stage actions component', () => {
expect(findStageFieldActions().exists()).toBe(true);
});
describe('with only 1 stage', () => {
beforeEach(() => {
wrapper = createComponent({ props: { totalStages: 1 } });
});
expect(wrapper.emitted().update[0]).toEqual(['endEventLabelId', firstLabel.id]); it('does not display the stage actions component', () => {
expect(findStageFieldActions().exists()).toBe(false);
}); });
}); });
}); });
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import StageFieldActions from 'ee/analytics/cycle_analytics/components/create_value_stream_form/stage_field_actions.vue'; import StageFieldActions from 'ee/analytics/cycle_analytics/components/create_value_stream_form/stage_field_actions.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
const defaultIndex = 0;
const stageCount = 3; const stageCount = 3;
describe('StageFieldActions', () => { describe('StageFieldActions', () => {
function createComponent({ index = defaultIndex }) { function createComponent({ index = 0, canRemove = false }) {
return shallowMount(StageFieldActions, { return extendedWrapper(
shallowMount(StageFieldActions, {
propsData: { propsData: {
index, index,
stageCount, stageCount,
canRemove,
}, },
}); }),
);
} }
let wrapper = null; let wrapper = null;
const findMoveDownBtn = () => wrapper.find('[data-testid^="stage-action-move-down"]'); const findMoveDownBtn = (index = 0) => wrapper.findByTestId(`stage-action-move-down-${index}`);
const findMoveUpBtn = () => wrapper.find('[data-testid^="stage-action-move-up"]'); const findMoveUpBtn = (index = 0) => wrapper.findByTestId(`stage-action-move-up-${index}`);
const findHideBtn = () => wrapper.find('[data-testid^="stage-action-hide"]'); const findHideBtn = (index = 0) => wrapper.findByTestId(`stage-action-hide-${index}`);
const findRemoveBtn = (index = 0) => wrapper.findByTestId(`stage-action-remove-${index}`);
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({}); wrapper = createComponent({});
...@@ -40,6 +44,10 @@ describe('StageFieldActions', () => { ...@@ -40,6 +44,10 @@ describe('StageFieldActions', () => {
expect(findHideBtn().exists()).toBe(true); expect(findHideBtn().exists()).toBe(true);
}); });
it('does not render the remove action', () => {
expect(findRemoveBtn().exists()).toBe(false);
});
it('disables the move up button', () => { it('disables the move up button', () => {
expect(findMoveUpBtn().props('disabled')).toBe(true); expect(findMoveUpBtn().props('disabled')).toBe(true);
}); });
...@@ -65,7 +73,21 @@ describe('StageFieldActions', () => { ...@@ -65,7 +73,21 @@ describe('StageFieldActions', () => {
}); });
it('disables the move down button', () => { it('disables the move down button', () => {
expect(findMoveDownBtn().props('disabled')).toBe(true); expect(findMoveDownBtn(2).props('disabled')).toBe(true);
});
});
describe('when canRemove=true', () => {
beforeEach(() => {
wrapper = createComponent({ canRemove: true });
});
it('will render the remove action', () => {
expect(findRemoveBtn().exists()).toBe(true);
});
it('does not render the hide action', () => {
expect(findHideBtn().exists()).toBe(false);
}); });
}); });
}); });
...@@ -102,6 +102,11 @@ describe('validateStage', () => { ...@@ -102,6 +102,11 @@ describe('validateStage', () => {
const result = validateStage({ ...defaultFields, [field]: value }); const result = validateStage({ ...defaultFields, [field]: value });
expectFieldError({ result, error, field }); expectFieldError({ result, error, field });
}); });
it(`returns "${ERRORS.END_EVENT_REQUIRED}" with a start event and no end event set`, () => {
const result = validateStage({ ...defaultFields, startEventIdentifier: 'start-event' });
expectFieldError({ result, error: ERRORS.END_EVENT_REQUIRED, field: 'endEventIdentifier' });
});
}); });
describe('validateValueStreamName,', () => { describe('validateValueStreamName,', () => {
...@@ -112,7 +117,7 @@ describe('validateValueStreamName,', () => { ...@@ -112,7 +117,7 @@ describe('validateValueStreamName,', () => {
it.each` it.each`
name | error | msg name | error | msg
${'a'.repeat(NAME_MAX_LENGTH + 1)} | ${ERRORS.MAX_LENGTH} | ${'too long'} ${'a'.repeat(NAME_MAX_LENGTH + 1)} | ${ERRORS.MAX_LENGTH} | ${'too long'}
${''} | ${ERRORS.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' }); expectFieldError({ result, error, field: 'name' });
......
...@@ -82,7 +82,7 @@ describe('CustomStageForm', () => { ...@@ -82,7 +82,7 @@ describe('CustomStageForm', () => {
const setFields = async (fields = minimumFields) => { const setFields = async (fields = minimumFields) => {
Object.entries(fields).forEach(([field, value]) => { Object.entries(fields).forEach(([field, value]) => {
wrapper.find(CustomStageFields).vm.$emit('update', field, value); wrapper.find(CustomStageFields).vm.$emit('input', { field, value });
}); });
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
}; };
......
...@@ -101,14 +101,14 @@ describe('Value Stream Analytics LabelsSelector', () => { ...@@ -101,14 +101,14 @@ describe('Value Stream Analytics LabelsSelector', () => {
}); });
it('will emit the "selectLabel" event', () => { it('will emit the "selectLabel" event', () => {
expect(wrapper.emitted('selectLabel')).toBeUndefined(); expect(wrapper.emitted('select-label')).toBeUndefined();
const elem = wrapper.findAll('.dropdown-item').at(1); const elem = wrapper.findAll('.dropdown-item').at(1);
elem.trigger('click'); elem.trigger('click');
return wrapper.vm.$nextTick().then(() => { return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('selectLabel').length > 0).toBe(true); expect(wrapper.emitted('select-label').length > 0).toBe(true);
expect(wrapper.emitted('selectLabel')[0]).toContain(groupLabels[1].id); expect(wrapper.emitted('select-label')[0]).toContain(groupLabels[1].id);
}); });
}); });
}); });
......
...@@ -2,6 +2,7 @@ import { GlModal } from '@gitlab/ui'; ...@@ -2,6 +2,7 @@ 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 { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { customStageEvents as formEvents } from '../mock_data'; import { customStageEvents as formEvents } from '../mock_data';
const localVue = createLocalVue(); const localVue = createLocalVue();
...@@ -37,6 +38,7 @@ describe('ValueStreamForm', () => { ...@@ -37,6 +38,7 @@ describe('ValueStreamForm', () => {
}); });
const createComponent = ({ props = {}, data = {}, initialState = {} } = {}) => const createComponent = ({ props = {}, data = {}, initialState = {} } = {}) =>
extendedWrapper(
shallowMount(ValueStreamForm, { shallowMount(ValueStreamForm, {
localVue, localVue,
store: fakeStore({ initialState }), store: fakeStore({ initialState }),
...@@ -53,13 +55,17 @@ describe('ValueStreamForm', () => { ...@@ -53,13 +55,17 @@ describe('ValueStreamForm', () => {
show: mockToastShow, show: mockToastShow,
}, },
}, },
}); }),
);
const findModal = () => wrapper.find(GlModal); const findModal = () => wrapper.find(GlModal);
const createSubmitButtonDisabledState = () => const clickSubmit = () => findModal().vm.$emit('primary', mockEvent);
findModal().props('actionPrimary').attributes[1].disabled; const clickAddStage = () => findModal().vm.$emit('secondary', mockEvent);
const submitModal = () => findModal().vm.$emit('primary', mockEvent); const findExtendedFormFields = () => wrapper.findByTestId('extended-form-fields');
const findExtendedFormFields = () => wrapper.find('[data-testid="extended-form-fields"]'); const findPresetSelector = () => wrapper.findByTestId('vsa-preset-selector');
const findBtn = (btn) => findModal().props(btn);
const findSubmitDisabledAttribute = (attribute) =>
findBtn('actionPrimary').attributes[1][attribute];
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -71,13 +77,23 @@ describe('ValueStreamForm', () => { ...@@ -71,13 +77,23 @@ describe('ValueStreamForm', () => {
wrapper = createComponent(); wrapper = createComponent();
}); });
it('submit button is disabled', () => { it('submit button is enabled', () => {
expect(createSubmitButtonDisabledState()).toBe(true); expect(findSubmitDisabledAttribute('disabled')).toBe(false);
}); });
it('does not include extended fields', () => { it('does not include extended fields', () => {
expect(findExtendedFormFields().exists()).toBe(false); expect(findExtendedFormFields().exists()).toBe(false);
}); });
it('does not include add stage button', () => {
expect(findBtn('actionSecondary').attributes).toContainEqual({
class: 'gl-display-none',
});
});
it('does not include the preset selector', () => {
expect(findPresetSelector().exists()).toBe(false);
});
}); });
describe('with hasExtendedFormFields=true', () => { describe('with hasExtendedFormFields=true', () => {
...@@ -88,6 +104,26 @@ describe('ValueStreamForm', () => { ...@@ -88,6 +104,26 @@ describe('ValueStreamForm', () => {
it('has the extended fields', () => { it('has the extended fields', () => {
expect(findExtendedFormFields().exists()).toBe(true); expect(findExtendedFormFields().exists()).toBe(true);
}); });
describe('Preset selector', () => {
it('has the preset button', () => {
expect(findPresetSelector().exists()).toBe(true);
});
});
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(6);
clickAddStage();
expect(wrapper.vm.stages.length).toBe(7);
});
});
}); });
describe('form errors', () => { describe('form errors', () => {
...@@ -101,7 +137,7 @@ describe('ValueStreamForm', () => { ...@@ -101,7 +137,7 @@ describe('ValueStreamForm', () => {
}); });
it('submit button is disabled', () => { it('submit button is disabled', () => {
expect(createSubmitButtonDisabledState()).toBe(true); expect(findSubmitDisabledAttribute('disabled')).toBe(true);
}); });
}); });
...@@ -111,12 +147,12 @@ describe('ValueStreamForm', () => { ...@@ -111,12 +147,12 @@ describe('ValueStreamForm', () => {
}); });
it('submit button is enabled', () => { it('submit button is enabled', () => {
expect(createSubmitButtonDisabledState()).toBe(false); expect(findSubmitDisabledAttribute('disabled')).toBe(false);
}); });
describe('form submitted successfully', () => { describe('form submitted successfully', () => {
beforeEach(() => { beforeEach(() => {
submitModal(); clickSubmit();
}); });
it('calls the "createValueStream" event when submitted', () => { it('calls the "createValueStream" event when submitted', () => {
...@@ -146,7 +182,7 @@ describe('ValueStreamForm', () => { ...@@ -146,7 +182,7 @@ describe('ValueStreamForm', () => {
}, },
}); });
submitModal(); clickSubmit();
}); });
it('calls the createValueStream action', () => { it('calls the createValueStream action', () => {
......
...@@ -8291,12 +8291,21 @@ msgstr "" ...@@ -8291,12 +8291,21 @@ msgstr ""
msgid "CreateValueStreamForm|'%{name}' Value Stream created" msgid "CreateValueStreamForm|'%{name}' Value Stream created"
msgstr "" msgstr ""
msgid "CreateValueStreamForm|Add another stage"
msgstr ""
msgid "CreateValueStreamForm|Add stage" msgid "CreateValueStreamForm|Add stage"
msgstr "" msgstr ""
msgid "CreateValueStreamForm|All default stages are currently visible" msgid "CreateValueStreamForm|All default stages are currently visible"
msgstr "" msgstr ""
msgid "CreateValueStreamForm|Create from default template"
msgstr ""
msgid "CreateValueStreamForm|Create from no template"
msgstr ""
msgid "CreateValueStreamForm|Default stages" msgid "CreateValueStreamForm|Default stages"
msgstr "" msgstr ""
...@@ -8312,16 +8321,13 @@ msgstr "" ...@@ -8312,16 +8321,13 @@ msgstr ""
msgid "CreateValueStreamForm|End event: " msgid "CreateValueStreamForm|End event: "
msgstr "" msgstr ""
msgid "CreateValueStreamForm|Enter a name for the stage"
msgstr ""
msgid "CreateValueStreamForm|Enter stage name" msgid "CreateValueStreamForm|Enter stage name"
msgstr "" msgstr ""
msgid "CreateValueStreamForm|Maximum length %{maxLength} characters" msgid "CreateValueStreamForm|Enter value stream name"
msgstr "" msgstr ""
msgid "CreateValueStreamForm|Name" msgid "CreateValueStreamForm|Maximum length %{maxLength} characters"
msgstr "" msgstr ""
msgid "CreateValueStreamForm|Name is required" msgid "CreateValueStreamForm|Name is required"
...@@ -8333,6 +8339,9 @@ msgstr "" ...@@ -8333,6 +8339,9 @@ msgstr ""
msgid "CreateValueStreamForm|Please select a start event first" msgid "CreateValueStreamForm|Please select a start event first"
msgstr "" msgstr ""
msgid "CreateValueStreamForm|Please select an end event"
msgstr ""
msgid "CreateValueStreamForm|Recover hidden stage" msgid "CreateValueStreamForm|Recover hidden stage"
msgstr "" msgstr ""
...@@ -8354,6 +8363,9 @@ msgstr "" ...@@ -8354,6 +8363,9 @@ msgstr ""
msgid "CreateValueStreamForm|Stage name already exists" msgid "CreateValueStreamForm|Stage name already exists"
msgstr "" msgstr ""
msgid "CreateValueStreamForm|Stage name is required"
msgstr ""
msgid "CreateValueStreamForm|Start event" msgid "CreateValueStreamForm|Start event"
msgstr "" msgstr ""
...@@ -8369,6 +8381,9 @@ msgstr "" ...@@ -8369,6 +8381,9 @@ msgstr ""
msgid "CreateValueStreamForm|Update stage" msgid "CreateValueStreamForm|Update stage"
msgstr "" msgstr ""
msgid "CreateValueStreamForm|Value Stream name"
msgstr ""
msgid "Created" msgid "Created"
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