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