Commit 8ca6899d authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'ek-extract-create-vsa-form' into 'master'

Move VSA create form modal into a component

See merge request gitlab-org/gitlab!81792
parents 91da6bc6 8efb82a2
<script> <script>
import { import { mapState } from 'vuex';
GlButton, import ValueStreamFormContent from './value_stream_form_content.vue';
GlForm, import { generateInitialStageData } from './create_value_stream_form/utils';
GlFormInput,
GlFormGroup,
GlFormRadioGroup,
GlLoadingIcon,
GlModal,
} from '@gitlab/ui';
import { cloneDeep, uniqueId } from 'lodash';
import Vue from 'vue';
import { mapState, mapActions } from 'vuex';
import { filterStagesByHiddenStatus } from '~/cycle_analytics/utils';
import { swapArrayItems } from '~/lib/utils/array_utility';
import { sprintf } from '~/locale';
import Tracking from '~/tracking';
import {
STAGE_SORT_DIRECTION,
i18n,
defaultCustomStageFields,
PRESET_OPTIONS,
PRESET_OPTIONS_DEFAULT,
} from './create_value_stream_form/constants';
import CustomStageFields from './create_value_stream_form/custom_stage_fields.vue';
import DefaultStageFields from './create_value_stream_form/default_stage_fields.vue';
import {
validateValueStreamName,
validateStage,
formatStageDataForSubmission,
hasDirtyStage,
} from './create_value_stream_form/utils';
const initializeStageErrors = (defaultStageConfig, selectedPreset = PRESET_OPTIONS_DEFAULT) =>
selectedPreset === PRESET_OPTIONS_DEFAULT ? defaultStageConfig.map(() => ({})) : [{}];
const initializeStages = (defaultStageConfig, selectedPreset = PRESET_OPTIONS_DEFAULT) => {
const stages =
selectedPreset === PRESET_OPTIONS_DEFAULT
? defaultStageConfig
: [{ ...defaultCustomStageFields }];
return stages.map((stage) => ({ ...stage, transitionKey: uniqueId('stage-') }));
};
const initializeEditingStages = (stages = []) =>
filterStagesByHiddenStatus(cloneDeep(stages), false).map((stage) => ({
...stage,
transitionKey: uniqueId(`stage-${stage.name}-`),
}));
export default { export default {
name: 'ValueStreamForm', name: 'ValueStreamForm',
components: { components: {
GlButton, ValueStreamFormContent,
GlForm,
GlFormInput,
GlFormGroup,
GlFormRadioGroup,
GlLoadingIcon,
GlModal,
DefaultStageFields,
CustomStageFields,
}, },
mixins: [Tracking.mixin()],
props: { props: {
initialData: {
type: Object,
required: false,
default: () => ({}),
},
initialPreset: {
type: String,
required: false,
default: PRESET_OPTIONS_DEFAULT,
},
initialFormErrors: {
type: Object,
required: false,
default: () => ({}),
},
defaultStageConfig: {
type: Array,
required: true,
},
isEditing: { isEditing: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
}, },
}, },
data() {
const {
defaultStageConfig = [],
initialData: { name: initialName, stages: initialStages = [] },
initialFormErrors,
initialPreset,
} = this;
const { name: nameError = [], stages: stageErrors = [{}] } = initialFormErrors;
const additionalFields = {
stages: this.isEditing
? initializeEditingStages(initialStages)
: initializeStages(defaultStageConfig, initialPreset),
stageErrors:
cloneDeep(stageErrors) || initializeStageErrors(defaultStageConfig, initialPreset),
};
return {
hiddenStages: filterStagesByHiddenStatus(initialStages),
selectedPreset: initialPreset,
presetOptions: PRESET_OPTIONS,
name: initialName,
nameError,
stageErrors,
...additionalFields,
};
},
computed: { computed: {
...mapState({ ...mapState({
isCreating: 'isCreatingValueStream', selectedValueStream: 'selectedValueStream',
isFetchingGroupLabels: 'isFetchingGroupLabels', selectedValueStreamStages: 'stages',
formEvents: 'formEvents', initialFormErrors: 'createValueStreamErrors',
defaultGroupLabels: 'defaultGroupLabels', defaultStageConfig: 'defaultStageConfig',
}), }),
isValueStreamNameValid() { initialData() {
return !this.nameError?.length; return this.isEditing
}, ? {
invalidNameFeedback() { ...this.selectedValueStream,
return this.nameError?.length ? this.nameError.join('\n\n') : null; stages: generateInitialStageData(
}, this.defaultStageConfig,
hasInitialFormErrors() { this.selectedValueStreamStages,
const { initialFormErrors } = this; ),
return Boolean(Object.keys(initialFormErrors).length); }
}, : {
isLoading() { name: '',
return this.isCreating; stages: [],
}, };
formTitle() {
return this.isEditing ? this.$options.i18n.EDIT_FORM_TITLE : this.$options.i18n.FORM_TITLE;
},
primaryProps() {
return {
text: this.isEditing ? this.$options.i18n.EDIT_FORM_ACTION : this.$options.i18n.FORM_TITLE,
attributes: [{ variant: 'success' }, { loading: this.isLoading }],
};
},
secondaryProps() {
return {
text: this.$options.i18n.BTN_ADD_ANOTHER_STAGE,
attributes: [{ category: 'secondary' }, { variant: 'info' }, { class: '' }],
};
},
hasFormErrors() {
return Boolean(
this.nameError.length || this.stageErrors.some((obj) => Object.keys(obj).length),
);
},
isDirtyEditing() {
return (
this.isEditing &&
(this.hasDirtyName(this.name, this.initialData.name) ||
hasDirtyStage(this.stages, this.initialData.stages))
);
},
canRestore() {
return this.hiddenStages.length || this.isDirtyEditing;
},
defaultValueStreamNames() {
return this.defaultStageConfig.map(({ name }) => name);
},
},
created() {
if (!this.defaultGroupLabels) {
this.fetchGroupLabels();
}
},
methods: {
...mapActions(['createValueStream', 'updateValueStream', 'fetchGroupLabels']),
onSubmit() {
this.validate();
if (this.hasFormErrors) return false;
let req = this.createValueStream;
let params = {
name: this.name,
stages: formatStageDataForSubmission(this.stages, this.isEditing),
};
if (this.isEditing) {
req = this.updateValueStream;
params = {
...params,
id: this.initialData.id,
};
}
return req(params).then(() => {
if (!this.hasInitialFormErrors) {
const msg = this.isEditing
? this.$options.i18n.FORM_EDITED
: this.$options.i18n.FORM_CREATED;
this.$toast.show(sprintf(msg, { name: this.name }));
this.name = '';
this.nameError = [];
this.stages = initializeStages(this.defaultStageConfig, this.selectedPreset);
this.stageErrors = initializeStageErrors(this.defaultStageConfig, this.selectedPreset);
this.track('submit_form', {
label: this.isEditing ? 'edit_value_stream' : 'create_value_stream',
});
}
});
},
stageGroupLabel(index) {
return sprintf(this.$options.i18n.STAGE_INDEX, { index: index + 1 });
},
recoverStageTitle(name) {
return sprintf(this.$options.i18n.HIDDEN_DEFAULT_STAGE, { name });
},
hasDirtyName(current, original) {
return current.trim().toLowerCase() !== original.trim().toLowerCase();
},
validateStages() {
return this.stages.map((stage) => validateStage(stage, this.defaultValueStreamNames));
},
validate() {
const { name } = this;
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 = this.moveItem(this.stages, index, direction);
const newErrors = this.moveItem(this.stageErrors, index, direction);
Vue.set(this, 'stages', cloneDeep(newStages));
Vue.set(this, 'stageErrors', cloneDeep(newErrors));
},
validateStageFields(index) {
Vue.set(this.stageErrors, index, validateStage(this.stages[index]));
},
fieldErrors(index) {
return this.stageErrors && this.stageErrors[index] ? this.stageErrors[index] : {};
},
onHide(index) {
const target = this.stages[index];
Vue.set(this, 'stages', [...this.stages.filter((_, i) => i !== index)]);
Vue.set(this, 'hiddenStages', [...this.hiddenStages, target]);
},
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 target = this.hiddenStages[hiddenStageIndex];
Vue.set(this, 'hiddenStages', [
...this.hiddenStages.filter((_, i) => i !== hiddenStageIndex),
]);
Vue.set(this, 'stages', [
...this.stages,
{ ...target, transitionKey: uniqueId(`stage-${target.name}-`) },
]);
},
lastStage() {
const stages = this.$refs.formStages;
return stages[stages.length - 1];
},
async scrollToLastStage() {
await this.$nextTick();
// Scroll to the new stage we have added
this.lastStage().focus();
this.lastStage().scrollIntoView({ behavior: 'smooth' });
},
addNewStage() {
// validate previous stages only and add a new stage
this.validate();
Vue.set(this, 'stages', [
...this.stages,
{ ...defaultCustomStageFields, transitionKey: uniqueId('stage-') },
]);
Vue.set(this, 'stageErrors', [...this.stageErrors, {}]);
},
onAddStage() {
this.addNewStage();
this.scrollToLastStage();
},
onFieldInput(activeStageIndex, { field, value }) {
const updatedStage = { ...this.stages[activeStageIndex], [field]: value };
Vue.set(this.stages, activeStageIndex, updatedStage);
},
resetAllFieldsToDefault() {
this.name = '';
Vue.set(this, 'stages', initializeStages(this.defaultStageConfig, this.selectedPreset));
Vue.set(
this,
'stageErrors',
initializeStageErrors(this.defaultStageConfig, this.selectedPreset),
);
},
handleResetDefaults() {
if (this.isEditing) {
const {
initialData: { name: initialName, stages: initialStages },
} = this;
Vue.set(this, 'name', initialName);
Vue.set(this, 'nameError', []);
Vue.set(this, 'stages', initializeStages(initialStages));
Vue.set(this, 'stageErrors', [{}]);
} else {
this.resetAllFieldsToDefault();
}
},
onSelectPreset() {
if (this.selectedPreset === PRESET_OPTIONS_DEFAULT) {
this.handleResetDefaults();
} else {
this.resetAllFieldsToDefault();
}
},
restoreActionTestId(index) {
return `stage-action-restore-${index}`;
}, },
}, },
i18n,
}; };
</script> </script>
<template> <template>
<gl-modal <value-stream-form-content
data-testid="value-stream-form-modal" :initial-data="initialData"
modal-id="value-stream-form-modal" :initial-form-errors="initialFormErrors"
dialog-class="gl-align-items-flex-start! gl-py-7" :default-stage-config="defaultStageConfig"
scrollable :is-editing="isEditing"
:title="formTitle" @hidden="$emit('hidden')"
:action-primary="primaryProps" />
:action-secondary="secondaryProps"
:action-cancel="{ text: $options.i18n.BTN_CANCEL }"
@hidden.prevent="$emit('hidden')"
@secondary.prevent="onAddStage"
@primary.prevent="onSubmit"
>
<gl-loading-icon v-if="isFetchingGroupLabels" size="lg" color="dark" class="gl-my-12" />
<gl-form v-else>
<gl-form-group
data-testid="create-value-stream-name"
label-for="create-value-stream-name"
:label="$options.i18n.FORM_FIELD_NAME_LABEL"
:invalid-feedback="invalidNameFeedback"
:state="isValueStreamNameValid"
>
<div class="gl-display-flex gl-justify-content-space-between">
<gl-form-input
id="create-value-stream-name"
v-model.trim="name"
name="create-value-stream-name"
:placeholder="$options.i18n.FORM_FIELD_NAME_PLACEHOLDER"
:state="isValueStreamNameValid"
required
/>
<transition name="fade">
<gl-button
v-if="canRestore"
data-testid="vsa-reset-button"
class="gl-ml-3"
variant="link"
@click="handleResetDefaults"
>{{ $options.i18n.RESTORE_DEFAULTS }}</gl-button
>
</transition>
</div>
</gl-form-group>
<gl-form-radio-group
v-if="!isEditing"
v-model="selectedPreset"
class="gl-mb-4"
data-testid="vsa-preset-selector"
:options="presetOptions"
name="preset"
@input="onSelectPreset"
/>
<div data-testid="extended-form-fields">
<transition-group name="stage-list" tag="div">
<div
v-for="(stage, activeStageIndex) in stages"
ref="formStages"
:key="stage.id || stage.transitionKey"
>
<hr class="gl-my-3" />
<custom-stage-fields
v-if="stage.custom"
:stage-label="stageGroupLabel(activeStageIndex)"
:stage="stage"
:stage-events="formEvents"
:index="activeStageIndex"
:total-stages="stages.length"
:errors="fieldErrors(activeStageIndex)"
:default-group-labels="defaultGroupLabels"
@move="handleMove"
@remove="onRemove"
@input="onFieldInput(activeStageIndex, $event)"
/>
<default-stage-fields
v-else
:stage-label="stageGroupLabel(activeStageIndex)"
:stage="stage"
:stage-events="formEvents"
:index="activeStageIndex"
:total-stages="stages.length"
:errors="fieldErrors(activeStageIndex)"
@move="handleMove"
@hide="onHide"
@input="validateStageFields(activeStageIndex)"
/>
</div>
</transition-group>
<div v-if="hiddenStages.length">
<hr />
<gl-form-group
v-for="(stage, hiddenStageIndex) in hiddenStages"
:key="stage.id"
data-testid="vsa-hidden-stage"
>
<span class="gl-m-0 gl-vertical-align-middle gl-mr-3 gl-font-weight-bold">{{
recoverStageTitle(stage.name)
}}</span>
<gl-button
variant="link"
:data-testid="restoreActionTestId(hiddenStageIndex)"
@click="onRestore(hiddenStageIndex)"
>{{ $options.i18n.RESTORE_HIDDEN_STAGE }}</gl-button
>
</gl-form-group>
</div>
</div>
</gl-form>
</gl-modal>
</template> </template>
<script>
import {
GlButton,
GlForm,
GlFormInput,
GlFormGroup,
GlFormRadioGroup,
GlLoadingIcon,
GlModal,
} from '@gitlab/ui';
import { cloneDeep, uniqueId } from 'lodash';
import Vue from 'vue';
import { mapState, mapActions } from 'vuex';
import { filterStagesByHiddenStatus } from '~/cycle_analytics/utils';
import { swapArrayItems } from '~/lib/utils/array_utility';
import { sprintf } from '~/locale';
import Tracking from '~/tracking';
import {
STAGE_SORT_DIRECTION,
i18n,
defaultCustomStageFields,
PRESET_OPTIONS,
PRESET_OPTIONS_DEFAULT,
} from './create_value_stream_form/constants';
import CustomStageFields from './create_value_stream_form/custom_stage_fields.vue';
import DefaultStageFields from './create_value_stream_form/default_stage_fields.vue';
import {
validateValueStreamName,
validateStage,
formatStageDataForSubmission,
hasDirtyStage,
} from './create_value_stream_form/utils';
const initializeStageErrors = (defaultStageConfig, selectedPreset = PRESET_OPTIONS_DEFAULT) =>
selectedPreset === PRESET_OPTIONS_DEFAULT ? defaultStageConfig.map(() => ({})) : [{}];
const initializeStages = (defaultStageConfig, selectedPreset = PRESET_OPTIONS_DEFAULT) => {
const stages =
selectedPreset === PRESET_OPTIONS_DEFAULT
? defaultStageConfig
: [{ ...defaultCustomStageFields }];
return stages.map((stage) => ({ ...stage, transitionKey: uniqueId('stage-') }));
};
const initializeEditingStages = (stages = []) =>
filterStagesByHiddenStatus(cloneDeep(stages), false).map((stage) => ({
...stage,
transitionKey: uniqueId(`stage-${stage.name}-`),
}));
export default {
name: 'ValueStreamFormContent',
components: {
GlButton,
GlForm,
GlFormInput,
GlFormGroup,
GlFormRadioGroup,
GlLoadingIcon,
GlModal,
DefaultStageFields,
CustomStageFields,
},
mixins: [Tracking.mixin()],
props: {
initialData: {
type: Object,
required: false,
default: () => ({}),
},
initialPreset: {
type: String,
required: false,
default: PRESET_OPTIONS_DEFAULT,
},
initialFormErrors: {
type: Object,
required: false,
default: () => ({}),
},
defaultStageConfig: {
type: Array,
required: true,
},
isEditing: {
type: Boolean,
required: false,
default: false,
},
},
data() {
const {
defaultStageConfig = [],
initialData: { name: initialName, stages: initialStages = [] },
initialFormErrors,
initialPreset,
} = this;
const { name: nameError = [], stages: stageErrors = [{}] } = initialFormErrors;
const additionalFields = {
stages: this.isEditing
? initializeEditingStages(initialStages)
: initializeStages(defaultStageConfig, initialPreset),
stageErrors:
cloneDeep(stageErrors) || initializeStageErrors(defaultStageConfig, initialPreset),
};
return {
hiddenStages: filterStagesByHiddenStatus(initialStages),
selectedPreset: initialPreset,
presetOptions: PRESET_OPTIONS,
name: initialName,
nameError,
stageErrors,
...additionalFields,
};
},
computed: {
...mapState({
isCreating: 'isCreatingValueStream',
isFetchingGroupLabels: 'isFetchingGroupLabels',
formEvents: 'formEvents',
defaultGroupLabels: 'defaultGroupLabels',
}),
isValueStreamNameValid() {
return !this.nameError?.length;
},
invalidNameFeedback() {
return this.nameError?.length ? this.nameError.join('\n\n') : null;
},
hasInitialFormErrors() {
const { initialFormErrors } = this;
return Boolean(Object.keys(initialFormErrors).length);
},
isLoading() {
return this.isCreating;
},
formTitle() {
return this.isEditing ? this.$options.i18n.EDIT_FORM_TITLE : this.$options.i18n.FORM_TITLE;
},
primaryProps() {
return {
text: this.isEditing ? this.$options.i18n.EDIT_FORM_ACTION : this.$options.i18n.FORM_TITLE,
attributes: [{ variant: 'success' }, { loading: this.isLoading }],
};
},
secondaryProps() {
return {
text: this.$options.i18n.BTN_ADD_ANOTHER_STAGE,
attributes: [{ category: 'secondary' }, { variant: 'info' }, { class: '' }],
};
},
hasFormErrors() {
return Boolean(
this.nameError.length || this.stageErrors.some((obj) => Object.keys(obj).length),
);
},
isDirtyEditing() {
return (
this.isEditing &&
(this.hasDirtyName(this.name, this.initialData.name) ||
hasDirtyStage(this.stages, this.initialData.stages))
);
},
canRestore() {
return this.hiddenStages.length || this.isDirtyEditing;
},
defaultValueStreamNames() {
return this.defaultStageConfig.map(({ name }) => name);
},
},
created() {
if (!this.defaultGroupLabels) {
this.fetchGroupLabels();
}
},
methods: {
...mapActions(['createValueStream', 'updateValueStream', 'fetchGroupLabels']),
onSubmit() {
this.validate();
if (this.hasFormErrors) return false;
let req = this.createValueStream;
let params = {
name: this.name,
stages: formatStageDataForSubmission(this.stages, this.isEditing),
};
if (this.isEditing) {
req = this.updateValueStream;
params = {
...params,
id: this.initialData.id,
};
}
return req(params).then(() => {
if (!this.hasInitialFormErrors) {
const msg = this.isEditing
? this.$options.i18n.FORM_EDITED
: this.$options.i18n.FORM_CREATED;
this.$toast.show(sprintf(msg, { name: this.name }));
this.name = '';
this.nameError = [];
this.stages = initializeStages(this.defaultStageConfig, this.selectedPreset);
this.stageErrors = initializeStageErrors(this.defaultStageConfig, this.selectedPreset);
this.track('submit_form', {
label: this.isEditing ? 'edit_value_stream' : 'create_value_stream',
});
}
});
},
stageGroupLabel(index) {
return sprintf(this.$options.i18n.STAGE_INDEX, { index: index + 1 });
},
recoverStageTitle(name) {
return sprintf(this.$options.i18n.HIDDEN_DEFAULT_STAGE, { name });
},
hasDirtyName(current, original) {
return current.trim().toLowerCase() !== original.trim().toLowerCase();
},
validateStages() {
return this.stages.map((stage) => validateStage(stage, this.defaultValueStreamNames));
},
validate() {
const { name } = this;
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 = this.moveItem(this.stages, index, direction);
const newErrors = this.moveItem(this.stageErrors, index, direction);
Vue.set(this, 'stages', cloneDeep(newStages));
Vue.set(this, 'stageErrors', cloneDeep(newErrors));
},
validateStageFields(index) {
Vue.set(this.stageErrors, index, validateStage(this.stages[index]));
},
fieldErrors(index) {
return this.stageErrors && this.stageErrors[index] ? this.stageErrors[index] : {};
},
onHide(index) {
const target = this.stages[index];
Vue.set(this, 'stages', [...this.stages.filter((_, i) => i !== index)]);
Vue.set(this, 'hiddenStages', [...this.hiddenStages, target]);
},
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 target = this.hiddenStages[hiddenStageIndex];
Vue.set(this, 'hiddenStages', [
...this.hiddenStages.filter((_, i) => i !== hiddenStageIndex),
]);
Vue.set(this, 'stages', [
...this.stages,
{ ...target, transitionKey: uniqueId(`stage-${target.name}-`) },
]);
},
lastStage() {
const stages = this.$refs.formStages;
return stages[stages.length - 1];
},
async scrollToLastStage() {
await this.$nextTick();
// Scroll to the new stage we have added
this.lastStage().focus();
this.lastStage().scrollIntoView({ behavior: 'smooth' });
},
addNewStage() {
// validate previous stages only and add a new stage
this.validate();
Vue.set(this, 'stages', [
...this.stages,
{ ...defaultCustomStageFields, transitionKey: uniqueId('stage-') },
]);
Vue.set(this, 'stageErrors', [...this.stageErrors, {}]);
},
onAddStage() {
this.addNewStage();
this.scrollToLastStage();
},
onFieldInput(activeStageIndex, { field, value }) {
const updatedStage = { ...this.stages[activeStageIndex], [field]: value };
Vue.set(this.stages, activeStageIndex, updatedStage);
},
resetAllFieldsToDefault() {
this.name = '';
Vue.set(this, 'stages', initializeStages(this.defaultStageConfig, this.selectedPreset));
Vue.set(
this,
'stageErrors',
initializeStageErrors(this.defaultStageConfig, this.selectedPreset),
);
},
handleResetDefaults() {
if (this.isEditing) {
const {
initialData: { name: initialName, stages: initialStages },
} = this;
Vue.set(this, 'name', initialName);
Vue.set(this, 'nameError', []);
Vue.set(this, 'stages', initializeStages(initialStages));
Vue.set(this, 'stageErrors', [{}]);
} else {
this.resetAllFieldsToDefault();
}
},
onSelectPreset() {
if (this.selectedPreset === PRESET_OPTIONS_DEFAULT) {
this.handleResetDefaults();
} else {
this.resetAllFieldsToDefault();
}
},
restoreActionTestId(index) {
return `stage-action-restore-${index}`;
},
},
i18n,
};
</script>
<template>
<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="formTitle"
:action-primary="primaryProps"
:action-secondary="secondaryProps"
:action-cancel="{ text: $options.i18n.BTN_CANCEL }"
@hidden.prevent="$emit('hidden')"
@secondary.prevent="onAddStage"
@primary.prevent="onSubmit"
>
<gl-loading-icon v-if="isFetchingGroupLabels" size="lg" color="dark" class="gl-my-12" />
<gl-form v-else>
<gl-form-group
data-testid="create-value-stream-name"
label-for="create-value-stream-name"
:label="$options.i18n.FORM_FIELD_NAME_LABEL"
:invalid-feedback="invalidNameFeedback"
:state="isValueStreamNameValid"
>
<div class="gl-display-flex gl-justify-content-space-between">
<gl-form-input
id="create-value-stream-name"
v-model.trim="name"
name="create-value-stream-name"
:placeholder="$options.i18n.FORM_FIELD_NAME_PLACEHOLDER"
:state="isValueStreamNameValid"
required
/>
<transition name="fade">
<gl-button
v-if="canRestore"
data-testid="vsa-reset-button"
class="gl-ml-3"
variant="link"
@click="handleResetDefaults"
>{{ $options.i18n.RESTORE_DEFAULTS }}</gl-button
>
</transition>
</div>
</gl-form-group>
<gl-form-radio-group
v-if="!isEditing"
v-model="selectedPreset"
class="gl-mb-4"
data-testid="vsa-preset-selector"
:options="presetOptions"
name="preset"
@input="onSelectPreset"
/>
<div data-testid="extended-form-fields">
<transition-group name="stage-list" tag="div">
<div
v-for="(stage, activeStageIndex) in stages"
ref="formStages"
:key="stage.id || stage.transitionKey"
>
<hr class="gl-my-3" />
<custom-stage-fields
v-if="stage.custom"
:stage-label="stageGroupLabel(activeStageIndex)"
:stage="stage"
:stage-events="formEvents"
:index="activeStageIndex"
:total-stages="stages.length"
:errors="fieldErrors(activeStageIndex)"
:default-group-labels="defaultGroupLabels"
@move="handleMove"
@remove="onRemove"
@input="onFieldInput(activeStageIndex, $event)"
/>
<default-stage-fields
v-else
:stage-label="stageGroupLabel(activeStageIndex)"
:stage="stage"
:stage-events="formEvents"
:index="activeStageIndex"
:total-stages="stages.length"
:errors="fieldErrors(activeStageIndex)"
@move="handleMove"
@hide="onHide"
@input="validateStageFields(activeStageIndex)"
/>
</div>
</transition-group>
<div v-if="hiddenStages.length">
<hr />
<gl-form-group
v-for="(stage, hiddenStageIndex) in hiddenStages"
:key="stage.id"
data-testid="vsa-hidden-stage"
>
<span class="gl-m-0 gl-vertical-align-middle gl-mr-3 gl-font-weight-bold">{{
recoverStageTitle(stage.name)
}}</span>
<gl-button
variant="link"
:data-testid="restoreActionTestId(hiddenStageIndex)"
@click="onRestore(hiddenStageIndex)"
>{{ $options.i18n.RESTORE_HIDDEN_STAGE }}</gl-button
>
</gl-form-group>
</div>
</div>
</gl-form>
</gl-modal>
</template>
...@@ -12,7 +12,6 @@ import { mapState, mapActions } from 'vuex'; ...@@ -12,7 +12,6 @@ import { mapState, mapActions } from 'vuex';
import { slugifyWithUnderscore } from '~/lib/utils/text_utility'; import { slugifyWithUnderscore } from '~/lib/utils/text_utility';
import { sprintf, __, s__ } from '~/locale'; import { sprintf, __, s__ } from '~/locale';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import { generateInitialStageData } from './create_value_stream_form/utils';
import ValueStreamForm from './value_stream_form.vue'; import ValueStreamForm from './value_stream_form.vue';
const i18n = { const i18n = {
...@@ -43,12 +42,8 @@ export default { ...@@ -43,12 +42,8 @@ export default {
mixins: [Tracking.mixin()], mixins: [Tracking.mixin()],
data() { data() {
return { return {
showCreateModal: false, showForm: false,
isEditing: false, isEditing: false,
initialData: {
name: '',
stages: [],
},
}; };
}, },
computed: { computed: {
...@@ -57,9 +52,6 @@ export default { ...@@ -57,9 +52,6 @@ export default {
deleteValueStreamError: 'deleteValueStreamError', deleteValueStreamError: 'deleteValueStreamError',
data: 'valueStreams', data: 'valueStreams',
selectedValueStream: 'selectedValueStream', selectedValueStream: 'selectedValueStream',
selectedValueStreamStages: 'stages',
initialFormErrors: 'createValueStreamErrors',
defaultStageConfig: 'defaultStageConfig',
}), }),
hasValueStreams() { hasValueStreams() {
return Boolean(this.data.length); return Boolean(this.data.length);
...@@ -100,24 +92,12 @@ export default { ...@@ -100,24 +92,12 @@ export default {
}); });
}, },
onCreate() { onCreate() {
this.showCreateModal = true; this.showForm = true;
this.isEditing = false; this.isEditing = false;
this.initialData = {
name: '',
stages: [],
};
}, },
onEdit() { onEdit() {
this.showCreateModal = true; this.showForm = true;
this.isEditing = true; this.isEditing = true;
const stages = generateInitialStageData(
this.defaultStageConfig,
this.selectedValueStreamStages,
);
this.initialData = {
...this.selectedValueStream,
stages,
};
}, },
slugify(valueStreamTitle) { slugify(valueStreamTitle) {
return slugifyWithUnderscore(valueStreamTitle); return slugifyWithUnderscore(valueStreamTitle);
...@@ -185,14 +165,7 @@ export default { ...@@ -185,14 +165,7 @@ export default {
@click="onCreate" @click="onCreate"
>{{ $options.i18n.CREATE_VALUE_STREAM }}</gl-button >{{ $options.i18n.CREATE_VALUE_STREAM }}</gl-button
> >
<value-stream-form <value-stream-form v-if="showForm" :is-editing="isEditing" @hidden="showForm = false" />
v-if="showCreateModal"
:initial-data="initialData"
:initial-form-errors="initialFormErrors"
:default-stage-config="defaultStageConfig"
:is-editing="isEditing"
@hidden="showCreateModal = false"
/>
<gl-modal <gl-modal
data-testid="delete-value-stream-modal" data-testid="delete-value-stream-modal"
modal-id="delete-value-stream-modal" modal-id="delete-value-stream-modal"
......
import { GlModal, GlFormInput } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import {
i18n,
PRESET_OPTIONS_BLANK,
PRESET_OPTIONS_DEFAULT,
} from 'ee/analytics/cycle_analytics/components/create_value_stream_form/constants';
import CustomStageFields from 'ee/analytics/cycle_analytics/components/create_value_stream_form/custom_stage_fields.vue';
import CustomStageEventField from 'ee/analytics/cycle_analytics/components/create_value_stream_form/custom_stage_event_field.vue';
import DefaultStageFields from 'ee/analytics/cycle_analytics/components/create_value_stream_form/default_stage_fields.vue';
import ValueStreamFormContent from 'ee/analytics/cycle_analytics/components/value_stream_form_content.vue';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import {
convertObjectPropsToCamelCase,
convertObjectPropsToSnakeCase,
} from '~/lib/utils/common_utils';
import {
customStageEvents as formEvents,
defaultStageConfig,
rawCustomStage,
groupLabels as defaultGroupLabels,
} from '../mock_data';
const scrollIntoViewMock = jest.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
Vue.use(Vuex);
describe('ValueStreamFormContent', () => {
let wrapper = null;
let trackingSpy = null;
const createValueStreamMock = jest.fn(() => Promise.resolve());
const updateValueStreamMock = jest.fn(() => Promise.resolve());
const fetchGroupLabelsMock = jest.fn(() => Promise.resolve());
const mockEvent = { preventDefault: jest.fn() };
const mockToastShow = jest.fn();
const streamName = 'Cool stream';
const initialFormErrors = { name: ['Name field required'] };
const initialFormStageErrors = {
stages: [
{
name: ['Name field is required'],
endEventIdentifier: ['Please select a start event first'],
},
],
};
const initialData = {
stages: [convertObjectPropsToCamelCase(rawCustomStage)],
id: 1337,
name: 'Editable value stream',
};
const initialPreset = PRESET_OPTIONS_DEFAULT;
const fakeStore = ({ state }) =>
new Vuex.Store({
state: {
isCreatingValueStream: false,
formEvents,
defaultGroupLabels,
...state,
},
actions: {
createValueStream: createValueStreamMock,
updateValueStream: updateValueStreamMock,
fetchGroupLabels: fetchGroupLabelsMock,
},
});
const createComponent = ({ props = {}, data = {}, stubs = {}, state = {} } = {}) =>
extendedWrapper(
shallowMount(ValueStreamFormContent, {
store: fakeStore({ state }),
data() {
return {
...data,
};
},
propsData: {
defaultStageConfig,
...props,
},
mocks: {
$toast: {
show: mockToastShow,
},
},
stubs: {
...stubs,
},
}),
);
const findModal = () => wrapper.findComponent(GlModal);
const findExtendedFormFields = () => wrapper.findByTestId('extended-form-fields');
const findPresetSelector = () => wrapper.findByTestId('vsa-preset-selector');
const findRestoreButton = () => wrapper.findByTestId('vsa-reset-button');
const findRestoreStageButton = (index) => wrapper.findByTestId(`stage-action-restore-${index}`);
const findHiddenStages = () => wrapper.findAllByTestId('vsa-hidden-stage').wrappers;
const findBtn = (btn) => findModal().props(btn);
const findCustomStageEventField = (index = 0) =>
wrapper.findAllComponents(CustomStageEventField).at(index);
const clickSubmit = () => findModal().vm.$emit('primary', mockEvent);
const clickAddStage = () => findModal().vm.$emit('secondary', mockEvent);
const clickRestoreStageAtIndex = (index) => findRestoreStageButton(index).vm.$emit('click');
const expectFieldError = (testId, error = '') =>
expect(wrapper.findByTestId(testId).attributes('invalid-feedback')).toBe(error);
const expectCustomFieldError = (index, attr, error = '') =>
expect(findCustomStageEventField(index).attributes(attr)).toBe(error);
const expectStageTransitionKeys = (stages) =>
stages.forEach((stage) => expect(stage.transitionKey).toContain('stage-'));
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('default state', () => {
beforeEach(() => {
wrapper = createComponent({ state: { defaultGroupLabels: null } });
});
it('has the extended fields', () => {
expect(findExtendedFormFields().exists()).toBe(true);
});
it('sets the submit action text to "Create Value Stream"', () => {
expect(findBtn('actionPrimary').text).toBe(i18n.FORM_TITLE);
});
describe('Preset selector', () => {
it('has the preset button', () => {
expect(findPresetSelector().exists()).toBe(true);
});
it('will toggle between the blank and default templates', () => {
expect(wrapper.vm.stages).toHaveLength(defaultStageConfig.length);
findPresetSelector().vm.$emit('input', PRESET_OPTIONS_BLANK);
expect(wrapper.vm.stages).toHaveLength(1);
findPresetSelector().vm.$emit('input', PRESET_OPTIONS_DEFAULT);
expect(wrapper.vm.stages).toHaveLength(defaultStageConfig.length);
});
});
it('each stage has a transition key when toggling', () => {
findPresetSelector().vm.$emit('input', PRESET_OPTIONS_BLANK);
expectStageTransitionKeys(wrapper.vm.stages);
findPresetSelector().vm.$emit('input', PRESET_OPTIONS_DEFAULT);
expectStageTransitionKeys(wrapper.vm.stages);
});
it('does not display any hidden stages', () => {
expect(findHiddenStages().length).toBe(0);
});
it('will fetch group labels', () => {
expect(fetchGroupLabelsMock).toHaveBeenCalled();
});
describe('Add stage button', () => {
beforeEach(() => {
wrapper = createComponent({
stubs: {
CustomStageFields,
},
});
});
it('has the add stage button', () => {
expect(findBtn('actionSecondary')).toMatchObject({ text: 'Add another stage' });
});
it('adds a blank custom stage when clicked', async () => {
expect(wrapper.vm.stages).toHaveLength(defaultStageConfig.length);
await clickAddStage();
expect(wrapper.vm.stages.length).toBe(defaultStageConfig.length + 1);
});
it('validates existing fields when clicked', async () => {
expect(wrapper.vm.nameError).toHaveLength(0);
await clickAddStage();
expect(wrapper.vm.nameError).toEqual(['Name is required']);
});
it('each stage has a transition key', () => {
expectStageTransitionKeys(wrapper.vm.stages);
});
});
describe('form errors', () => {
const commonExtendedData = {
props: {
initialFormErrors: initialFormStageErrors,
},
};
it('renders errors for a default stage field', () => {
wrapper = createComponent({
...commonExtendedData,
stubs: {
DefaultStageFields,
},
});
expectFieldError('default-stage-name-0', initialFormStageErrors.stages[0].name[0]);
});
it('renders errors for a custom stage field', async () => {
wrapper = createComponent({
props: {
...commonExtendedData.props,
initialPreset: PRESET_OPTIONS_BLANK,
},
stubs: {
CustomStageFields,
},
});
expectFieldError('custom-stage-name-0', initialFormStageErrors.stages[0].name[0]);
expectCustomFieldError(
1,
'identifiererror',
initialFormStageErrors.stages[0].endEventIdentifier[0],
);
});
});
describe('isEditing=true', () => {
const stageCount = initialData.stages.length;
beforeEach(() => {
wrapper = createComponent({
props: {
initialPreset,
initialData,
isEditing: true,
},
});
});
it('does not have the preset button', () => {
expect(findPresetSelector().exists()).toBe(false);
});
it('sets the submit action text to "Save value stream"', () => {
expect(findBtn('actionPrimary').text).toBe(i18n.EDIT_FORM_ACTION);
});
it('does not display any hidden stages', () => {
expect(findHiddenStages().length).toBe(0);
});
it('each stage has a transition key', () => {
expectStageTransitionKeys(wrapper.vm.stages);
});
describe('restore defaults button', () => {
it('will clear the form fields', async () => {
expect(wrapper.vm.stages).toHaveLength(stageCount);
await clickAddStage();
expect(wrapper.vm.stages).toHaveLength(stageCount + 1);
findRestoreButton().vm.$emit('click');
expect(wrapper.vm.stages).toHaveLength(stageCount);
});
});
describe('with hidden stages', () => {
const hiddenStages = defaultStageConfig.map((s) => ({ ...s, hidden: true }));
beforeEach(() => {
wrapper = createComponent({
props: {
initialPreset,
initialData: { ...initialData, stages: [...initialData.stages, ...hiddenStages] },
isEditing: true,
},
});
});
it('displays hidden each stage', () => {
expect(findHiddenStages().length).toBe(hiddenStages.length);
findHiddenStages().forEach((s) => {
expect(s.text()).toContain('Restore stage');
});
});
it('when `Restore stage` is clicked, the stage is restored', async () => {
await clickRestoreStageAtIndex(1);
expect(findHiddenStages().length).toBe(hiddenStages.length - 1);
expect(wrapper.vm.stages.length).toBe(stageCount + 1);
});
it('when a stage is restored it has a transition key', async () => {
await clickRestoreStageAtIndex(1);
expect(wrapper.vm.stages[stageCount].transitionKey).toContain(
`stage-${hiddenStages[1].name}-`,
);
});
});
describe('Add stage button', () => {
beforeEach(() => {
wrapper = createComponent({
props: {
initialPreset,
initialData,
isEditing: true,
},
stubs: {
CustomStageFields,
},
});
});
it('has the add stage button', () => {
expect(findBtn('actionSecondary')).toMatchObject({ text: i18n.BTN_ADD_ANOTHER_STAGE });
});
it('adds a blank custom stage when clicked', async () => {
expect(wrapper.vm.stages.length).toBe(stageCount);
await clickAddStage();
expect(wrapper.vm.stages.length).toBe(stageCount + 1);
});
it('validates existing fields when clicked', async () => {
expect(wrapper.vm.nameError).toEqual([]);
wrapper
.findByTestId('create-value-stream-name')
.findComponent(GlFormInput)
.vm.$emit('input', '');
await clickAddStage();
expect(wrapper.vm.nameError).toEqual(['Name is required']);
});
});
describe('with valid fields', () => {
beforeEach(() => {
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
wrapper = createComponent({
props: {
initialPreset,
initialData,
isEditing: true,
},
});
});
afterEach(() => {
unmockTracking();
wrapper.destroy();
});
describe('form submitted successfully', () => {
beforeEach(() => {
clickSubmit();
});
it('calls the "updateValueStreamMock" event when submitted', () => {
expect(updateValueStreamMock).toHaveBeenCalledWith(expect.any(Object), {
...initialData,
stages: initialData.stages.map((stage) =>
convertObjectPropsToSnakeCase(stage, { deep: true }),
),
});
});
it('displays a toast message', () => {
expect(mockToastShow).toHaveBeenCalledWith(`'${initialData.name}' Value Stream saved`);
});
it('sends tracking information', () => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'submit_form', {
label: 'edit_value_stream',
});
});
});
describe('form submission fails', () => {
beforeEach(() => {
wrapper = createComponent({
data: { name: streamName },
props: {
initialFormErrors,
},
});
clickSubmit();
});
it('does not call the updateValueStreamMock action', () => {
expect(updateValueStreamMock).not.toHaveBeenCalled();
});
it('does not clear the name field', () => {
expect(wrapper.vm.name).toBe(streamName);
});
it('does not display a toast message', () => {
expect(mockToastShow).not.toHaveBeenCalled();
});
});
});
});
});
describe('defaultGroupLabels set', () => {
beforeEach(() => {
wrapper = createComponent({
state: { defaultGroupLabels: [] },
});
});
it('does not fetch group labels', () => {
expect(fetchGroupLabelsMock).not.toHaveBeenCalled();
});
});
describe('form errors', () => {
beforeEach(() => {
wrapper = createComponent({
data: { name: '' },
props: {
initialFormErrors,
},
});
});
it('renders errors for the name field', () => {
expectFieldError('create-value-stream-name', initialFormErrors.name[0]);
});
});
describe('with valid fields', () => {
beforeEach(() => {
wrapper = createComponent({ data: { name: streamName } });
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
afterEach(() => {
unmockTracking();
wrapper.destroy();
});
describe('form submitted successfully', () => {
beforeEach(() => {
clickSubmit();
});
it('calls the "createValueStream" event when submitted', () => {
expect(createValueStreamMock).toHaveBeenCalledWith(expect.any(Object), {
name: streamName,
stages: [
{
custom: false,
name: 'issue',
},
{
custom: false,
name: 'plan',
},
{
custom: false,
name: 'code',
},
],
});
});
it('clears the name field', () => {
expect(wrapper.vm.name).toBe('');
});
it('displays a toast message', () => {
expect(mockToastShow).toHaveBeenCalledWith(`'${streamName}' Value Stream created`);
});
it('sends tracking information', () => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'submit_form', {
label: 'create_value_stream',
});
});
});
describe('form submission fails', () => {
beforeEach(() => {
wrapper = createComponent({
data: { name: streamName },
props: {
initialFormErrors,
},
});
clickSubmit();
});
it('calls the createValueStream action', () => {
expect(createValueStreamMock).toHaveBeenCalled();
});
it('does not clear the name field', () => {
expect(wrapper.vm.name).toBe(streamName);
});
it('does not display a toast message', () => {
expect(mockToastShow).not.toHaveBeenCalled();
});
});
});
});
import { GlModal, GlFormInput } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
i18n,
PRESET_OPTIONS_BLANK,
PRESET_OPTIONS_DEFAULT,
} from 'ee/analytics/cycle_analytics/components/create_value_stream_form/constants';
import CustomStageFields from 'ee/analytics/cycle_analytics/components/create_value_stream_form/custom_stage_fields.vue';
import CustomStageEventField from 'ee/analytics/cycle_analytics/components/create_value_stream_form/custom_stage_event_field.vue';
import DefaultStageFields from 'ee/analytics/cycle_analytics/components/create_value_stream_form/default_stage_fields.vue';
import ValueStreamForm from 'ee/analytics/cycle_analytics/components/value_stream_form.vue'; import ValueStreamForm from 'ee/analytics/cycle_analytics/components/value_stream_form.vue';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import ValueStreamFormContent from 'ee/analytics/cycle_analytics/components/value_stream_form_content.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { import { rawCustomStage, valueStreams, defaultStageConfig } from '../mock_data';
convertObjectPropsToCamelCase,
convertObjectPropsToSnakeCase,
} from '~/lib/utils/common_utils';
import {
customStageEvents as formEvents,
defaultStageConfig,
rawCustomStage,
groupLabels as defaultGroupLabels,
} from '../mock_data';
const scrollIntoViewMock = jest.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
Vue.use(Vuex); Vue.use(Vuex);
describe('ValueStreamForm', () => { const [selectedValueStream] = valueStreams;
let wrapper = null; const camelCustomStage = convertObjectPropsToCamelCase(rawCustomStage);
let trackingSpy = null; const stages = [camelCustomStage];
const initialData = { name: '', stages: [] };
const createValueStreamMock = jest.fn(() => Promise.resolve());
const updateValueStreamMock = jest.fn(() => Promise.resolve()); const fakeStore = ({ state }) =>
const fetchGroupLabelsMock = jest.fn(() => Promise.resolve()); new Vuex.Store({
const mockEvent = { preventDefault: jest.fn() }; state: {
const mockToastShow = jest.fn(); createValueStreamErrors: {},
const streamName = 'Cool stream'; defaultStageConfig,
const initialFormErrors = { name: ['Name field required'] }; ...state,
const initialFormStageErrors = { },
stages: [ });
{
name: ['Name field is required'],
endEventIdentifier: ['Please select a start event first'],
},
],
};
const initialData = {
stages: [convertObjectPropsToCamelCase(rawCustomStage)],
id: 1337,
name: 'Editable value stream',
};
const initialPreset = PRESET_OPTIONS_DEFAULT;
const fakeStore = ({ state }) => const createComponent = ({ props = {}, state = {} } = {}) =>
new Vuex.Store({ extendedWrapper(
state: { shallowMount(ValueStreamForm, {
isCreatingValueStream: false, store: fakeStore({ state }),
formEvents, propsData: {
defaultGroupLabels, defaultStageConfig,
...state, ...props,
}, },
actions: { }),
createValueStream: createValueStreamMock, );
updateValueStream: updateValueStreamMock,
fetchGroupLabels: fetchGroupLabelsMock,
},
});
const createComponent = ({ props = {}, data = {}, stubs = {}, state = {} } = {}) => describe('ValueStreamForm', () => {
extendedWrapper( let wrapper = null;
shallowMount(ValueStreamForm, {
store: fakeStore({ state }),
data() {
return {
...data,
};
},
propsData: {
defaultStageConfig,
...props,
},
mocks: {
$toast: {
show: mockToastShow,
},
},
stubs: {
...stubs,
},
}),
);
const findModal = () => wrapper.findComponent(GlModal);
const findExtendedFormFields = () => wrapper.findByTestId('extended-form-fields');
const findPresetSelector = () => wrapper.findByTestId('vsa-preset-selector');
const findRestoreButton = () => wrapper.findByTestId('vsa-reset-button');
const findRestoreStageButton = (index) => wrapper.findByTestId(`stage-action-restore-${index}`);
const findHiddenStages = () => wrapper.findAllByTestId('vsa-hidden-stage').wrappers;
const findBtn = (btn) => findModal().props(btn);
const findCustomStageEventField = (index = 0) =>
wrapper.findAllComponents(CustomStageEventField).at(index);
const clickSubmit = () => findModal().vm.$emit('primary', mockEvent); const findForm = () => wrapper.findComponent(ValueStreamFormContent);
const clickAddStage = () => findModal().vm.$emit('secondary', mockEvent); const findFormProps = () => findForm().props();
const clickRestoreStageAtIndex = (index) => findRestoreStageButton(index).vm.$emit('click');
const expectFieldError = (testId, error = '') =>
expect(wrapper.findByTestId(testId).attributes('invalid-feedback')).toBe(error);
const expectCustomFieldError = (index, attr, error = '') =>
expect(findCustomStageEventField(index).attributes(attr)).toBe(error);
const expectStageTransitionKeys = (stages) =>
stages.forEach((stage) => expect(stage.transitionKey).toContain('stage-'));
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
describe('default state', () => { describe('default state', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ state: { defaultGroupLabels: null } }); wrapper = createComponent();
});
it('has the extended fields', () => {
expect(findExtendedFormFields().exists()).toBe(true);
});
it('sets the submit action text to "Create Value Stream"', () => {
expect(findBtn('actionPrimary').text).toBe(i18n.FORM_TITLE);
}); });
describe('Preset selector', () => { it('renders the form component', () => {
it('has the preset button', () => { expect(findForm().exists()).toBe(true);
expect(findPresetSelector().exists()).toBe(true); expect(findFormProps()).toMatchObject({
}); defaultStageConfig,
initialData,
it('will toggle between the blank and default templates', () => { isEditing: false,
expect(wrapper.vm.stages).toHaveLength(defaultStageConfig.length);
findPresetSelector().vm.$emit('input', PRESET_OPTIONS_BLANK);
expect(wrapper.vm.stages).toHaveLength(1);
findPresetSelector().vm.$emit('input', PRESET_OPTIONS_DEFAULT);
expect(wrapper.vm.stages).toHaveLength(defaultStageConfig.length);
}); });
}); });
it('each stage has a transition key when toggling', () => { it('emits `hidden` when the modal is hidden', () => {
findPresetSelector().vm.$emit('input', PRESET_OPTIONS_BLANK); expect(wrapper.emitted('hidden')).toBeUndefined();
expectStageTransitionKeys(wrapper.vm.stages);
findPresetSelector().vm.$emit('input', PRESET_OPTIONS_DEFAULT);
expectStageTransitionKeys(wrapper.vm.stages);
});
it('does not display any hidden stages', () => {
expect(findHiddenStages().length).toBe(0);
});
it('will fetch group labels', () => {
expect(fetchGroupLabelsMock).toHaveBeenCalled();
});
describe('Add stage button', () => {
beforeEach(() => {
wrapper = createComponent({
stubs: {
CustomStageFields,
},
});
});
it('has the add stage button', () => {
expect(findBtn('actionSecondary')).toMatchObject({ text: 'Add another stage' });
});
it('adds a blank custom stage when clicked', async () => {
expect(wrapper.vm.stages).toHaveLength(defaultStageConfig.length);
await clickAddStage();
expect(wrapper.vm.stages.length).toBe(defaultStageConfig.length + 1);
});
it('validates existing fields when clicked', async () => {
expect(wrapper.vm.nameError).toHaveLength(0);
await clickAddStage();
expect(wrapper.vm.nameError).toEqual(['Name is required']);
});
it('each stage has a transition key', () => {
expectStageTransitionKeys(wrapper.vm.stages);
});
});
describe('form errors', () => {
const commonExtendedData = {
props: {
initialFormErrors: initialFormStageErrors,
},
};
it('renders errors for a default stage field', () => {
wrapper = createComponent({
...commonExtendedData,
stubs: {
DefaultStageFields,
},
});
expectFieldError('default-stage-name-0', initialFormStageErrors.stages[0].name[0]);
});
it('renders errors for a custom stage field', async () => {
wrapper = createComponent({
props: {
...commonExtendedData.props,
initialPreset: PRESET_OPTIONS_BLANK,
},
stubs: {
CustomStageFields,
},
});
expectFieldError('custom-stage-name-0', initialFormStageErrors.stages[0].name[0]);
expectCustomFieldError(
1,
'identifiererror',
initialFormStageErrors.stages[0].endEventIdentifier[0],
);
});
});
describe('isEditing=true', () => {
const stageCount = initialData.stages.length;
beforeEach(() => {
wrapper = createComponent({
props: {
initialPreset,
initialData,
isEditing: true,
},
});
});
it('does not have the preset button', () => {
expect(findPresetSelector().exists()).toBe(false);
});
it('sets the submit action text to "Save value stream"', () => {
expect(findBtn('actionPrimary').text).toBe(i18n.EDIT_FORM_ACTION);
});
it('does not display any hidden stages', () => {
expect(findHiddenStages().length).toBe(0);
});
it('each stage has a transition key', () => {
expectStageTransitionKeys(wrapper.vm.stages);
});
describe('restore defaults button', () => {
it('will clear the form fields', async () => {
expect(wrapper.vm.stages).toHaveLength(stageCount);
await clickAddStage();
expect(wrapper.vm.stages).toHaveLength(stageCount + 1);
findRestoreButton().vm.$emit('click');
expect(wrapper.vm.stages).toHaveLength(stageCount);
});
});
describe('with hidden stages', () => {
const hiddenStages = defaultStageConfig.map((s) => ({ ...s, hidden: true }));
beforeEach(() => {
wrapper = createComponent({
props: {
initialPreset,
initialData: { ...initialData, stages: [...initialData.stages, ...hiddenStages] },
isEditing: true,
},
});
});
it('displays hidden each stage', () => {
expect(findHiddenStages().length).toBe(hiddenStages.length);
findHiddenStages().forEach((s) => {
expect(s.text()).toContain('Restore stage');
});
});
it('when `Restore stage` is clicked, the stage is restored', async () => {
await clickRestoreStageAtIndex(1);
expect(findHiddenStages().length).toBe(hiddenStages.length - 1);
expect(wrapper.vm.stages.length).toBe(stageCount + 1);
});
it('when a stage is restored it has a transition key', async () => {
await clickRestoreStageAtIndex(1);
expect(wrapper.vm.stages[stageCount].transitionKey).toContain(
`stage-${hiddenStages[1].name}-`,
);
});
});
describe('Add stage button', () => {
beforeEach(() => {
wrapper = createComponent({
props: {
initialPreset,
initialData,
isEditing: true,
},
stubs: {
CustomStageFields,
},
});
});
it('has the add stage button', () => {
expect(findBtn('actionSecondary')).toMatchObject({ text: i18n.BTN_ADD_ANOTHER_STAGE });
});
it('adds a blank custom stage when clicked', async () => {
expect(wrapper.vm.stages.length).toBe(stageCount);
await clickAddStage();
expect(wrapper.vm.stages.length).toBe(stageCount + 1);
});
it('validates existing fields when clicked', async () => {
expect(wrapper.vm.nameError).toEqual([]);
wrapper
.findByTestId('create-value-stream-name')
.findComponent(GlFormInput)
.vm.$emit('input', '');
await clickAddStage();
expect(wrapper.vm.nameError).toEqual(['Name is required']);
});
});
describe('with valid fields', () => {
beforeEach(() => {
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
wrapper = createComponent({
props: {
initialPreset,
initialData,
isEditing: true,
},
});
});
afterEach(() => {
unmockTracking();
wrapper.destroy();
});
describe('form submitted successfully', () => {
beforeEach(() => {
clickSubmit();
});
it('calls the "updateValueStreamMock" event when submitted', () => {
expect(updateValueStreamMock).toHaveBeenCalledWith(expect.any(Object), {
...initialData,
stages: initialData.stages.map((stage) =>
convertObjectPropsToSnakeCase(stage, { deep: true }),
),
});
});
it('displays a toast message', () => {
expect(mockToastShow).toHaveBeenCalledWith(`'${initialData.name}' Value Stream saved`);
});
it('sends tracking information', () => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'submit_form', {
label: 'edit_value_stream',
});
});
});
describe('form submission fails', () => {
beforeEach(() => {
wrapper = createComponent({
data: { name: streamName },
props: {
initialFormErrors,
},
});
clickSubmit(); findForm().vm.$emit('hidden');
});
it('does not call the updateValueStreamMock action', () => { expect(wrapper.emitted('hidden')).toHaveLength(1);
expect(updateValueStreamMock).not.toHaveBeenCalled();
});
it('does not clear the name field', () => {
expect(wrapper.vm.name).toBe(streamName);
});
it('does not display a toast message', () => {
expect(mockToastShow).not.toHaveBeenCalled();
});
});
});
}); });
}); });
describe('defaultGroupLabels set', () => { describe('when editing', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ wrapper = createComponent({
state: { defaultGroupLabels: [] }, props: { isEditing: true },
state: { selectedValueStream, stages },
}); });
}); });
it('does not fetch group labels', () => { it('sets the form initialData', () => {
expect(fetchGroupLabelsMock).not.toHaveBeenCalled(); const props = findFormProps();
}); expect(props.initialData).toMatchObject({
}); id: selectedValueStream.id,
name: selectedValueStream.name,
describe('form errors', () => { stages: [
beforeEach(() => { camelCustomStage,
wrapper = createComponent({ ...defaultStageConfig.map(({ custom, name }) => ({ custom, name, hidden: true })),
data: { name: '' }, ],
props: {
initialFormErrors,
},
}); });
}); });
it('renders errors for the name field', () => {
expectFieldError('create-value-stream-name', initialFormErrors.name[0]);
});
}); });
describe('with valid fields', () => { describe('with createValueStreamErrors', () => {
const nameError = "Name can't be blank";
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ data: { name: streamName } }); wrapper = createComponent({
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); state: { createValueStreamErrors: { name: nameError } },
});
afterEach(() => {
unmockTracking();
wrapper.destroy();
});
describe('form submitted successfully', () => {
beforeEach(() => {
clickSubmit();
});
it('calls the "createValueStream" event when submitted', () => {
expect(createValueStreamMock).toHaveBeenCalledWith(expect.any(Object), {
name: streamName,
stages: [
{
custom: false,
name: 'issue',
},
{
custom: false,
name: 'plan',
},
{
custom: false,
name: 'code',
},
],
});
});
it('clears the name field', () => {
expect(wrapper.vm.name).toBe('');
});
it('displays a toast message', () => {
expect(mockToastShow).toHaveBeenCalledWith(`'${streamName}' Value Stream created`);
});
it('sends tracking information', () => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'submit_form', {
label: 'create_value_stream',
});
}); });
}); });
describe('form submission fails', () => { it('sets the form initialFormErrors', () => {
beforeEach(() => { const props = findFormProps();
wrapper = createComponent({ expect(props.initialFormErrors).toEqual({ name: nameError });
data: { name: streamName },
props: {
initialFormErrors,
},
});
clickSubmit();
});
it('calls the createValueStream action', () => {
expect(createValueStreamMock).toHaveBeenCalled();
});
it('does not clear the name field', () => {
expect(wrapper.vm.name).toBe(streamName);
});
it('does not display a toast message', () => {
expect(mockToastShow).not.toHaveBeenCalled();
});
}); });
}); });
}); });
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