Commit 32edb533 authored by Vitaly Slobodin's avatar Vitaly Slobodin

Merge branch '267536-vsa-create-form-from-default' into 'master'

Preconfigure fields for a default value stream

See merge request gitlab-org/gitlab!49094
parents 97811a03 26ff399c
...@@ -5,9 +5,10 @@ export const NAME_MAX_LENGTH = 100; ...@@ -5,9 +5,10 @@ export const NAME_MAX_LENGTH = 100;
export const I18N = { export const I18N = {
FORM_TITLE: __('Create Value Stream'), FORM_TITLE: __('Create Value Stream'),
FORM_CREATED: __("'%{name}' Value Stream created"), FORM_CREATED: s__("CreateValueStreamForm|'%{name}' Value Stream created"),
RECOVER_HIDDEN_STAGE: s__('CreateValueStreamForm|Recover hidden stage'), RECOVER_HIDDEN_STAGE: s__('CreateValueStreamForm|Recover hidden stage'),
RESTORE_HIDDEN_STAGE: s__('CreateValueStreamForm|Restore stage'), RESTORE_HIDDEN_STAGE: s__('CreateValueStreamForm|Restore stage'),
RESTORE_STAGES: s__('CreateValueStreamForm|Restore defaults'),
RECOVER_STAGE_TITLE: s__('CreateValueStreamForm|Default stages'), RECOVER_STAGE_TITLE: s__('CreateValueStreamForm|Default stages'),
RECOVER_STAGES_VISIBLE: s__('CreateValueStreamForm|All default stages are currently visible'), RECOVER_STAGES_VISIBLE: s__('CreateValueStreamForm|All default stages are currently visible'),
SELECT_START_EVENT: s__('CreateValueStreamForm|Select start event'), SELECT_START_EVENT: s__('CreateValueStreamForm|Select start event'),
...@@ -65,15 +66,49 @@ export const defaultFields = { ...@@ -65,15 +66,49 @@ export const defaultFields = {
endEventLabelId: null, endEventLabelId: null,
}; };
export const DEFAULT_STAGE_CONFIG = ['issue', 'plan', 'code', 'test', 'review', 'staging'].map( const defaultStageCommonFields = { custom: false, hidden: false };
(id) => ({
id, /**
name: capitalizeFirstCharacter(id), * These stage configs are copied from the https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/cycle_analytics
startEventIdentifier: null, * This is a stopgap solution and we should eventually provide these from an API endpoint
endEventIdentifier: null, *
startEventLabel: null, * More information: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49094#note_464116439
endEventLabel: null, */
custom: false, const BASE_DEFAULT_STAGE_CONFIG = [
hidden: false, {
}), id: 'issue',
); startEventIdentifier: ['issue_created'],
endEventIdentifier: ['issue_first_associated_with_milestone', 'issue_first_added_to_board'],
},
{
id: 'plan',
startEventIdentifier: ['issue_first_associated_with_milestone', 'issue_first_added_to_board'],
endEventIdentifier: ['issue_first_mentioned_in_commit'],
},
{
id: 'code',
startEventIdentifier: ['issue_first_mentioned_in_commit'],
endEventIdentifier: ['merge_request_created'],
},
{
id: 'test',
startEventIdentifier: ['merge_request_last_build_started'],
endEventIdentifier: ['merge_request_last_build_finished'],
},
{
id: 'review',
startEventIdentifier: ['merge_request_created'],
endEventIdentifier: ['merge_request_merged'],
},
{
id: 'staging',
startEventIdentifier: ['merge_request_merged'],
endEventIdentifier: ['merge_request_first_deployed_to_production'],
},
];
export const DEFAULT_STAGE_CONFIG = BASE_DEFAULT_STAGE_CONFIG.map(({ id, ...rest }) => ({
...rest,
...defaultStageCommonFields,
name: capitalizeFirstCharacter(id),
}));
...@@ -3,6 +3,19 @@ import { GlFormGroup, GlFormInput, GlFormText } from '@gitlab/ui'; ...@@ -3,6 +3,19 @@ import { GlFormGroup, GlFormInput, GlFormText } from '@gitlab/ui';
import StageFieldActions from './stage_field_actions.vue'; import StageFieldActions from './stage_field_actions.vue';
import { I18N } from './constants'; import { I18N } from './constants';
const findStageEvent = (stageEvents = [], eid = null) => {
if (!eid) return '';
return stageEvents.find(({ identifier }) => identifier === eid);
};
const eventIdsToName = (stageEvents = [], eventIds = []) =>
eventIds
.map((eid) => {
const stage = findStageEvent(stageEvents, eid);
return stage?.name || '';
})
.join(', ');
export default { export default {
name: 'DefaultStageFields', name: 'DefaultStageFields',
components: { components: {
...@@ -29,6 +42,10 @@ export default { ...@@ -29,6 +42,10 @@ export default {
required: false, required: false,
default: () => {}, default: () => {},
}, },
stageEvents: {
type: Array,
required: true,
},
}, },
methods: { methods: {
isValid(field) { isValid(field) {
...@@ -37,6 +54,9 @@ export default { ...@@ -37,6 +54,9 @@ export default {
renderError(field) { renderError(field) {
return this.errors[field]?.join('\n'); return this.errors[field]?.join('\n');
}, },
eventName(eventIds = []) {
return eventIdsToName(this.stageEvents, eventIds);
},
}, },
I18N, I18N,
}; };
...@@ -65,19 +85,19 @@ export default { ...@@ -65,19 +85,19 @@ export default {
/> />
</div> </div>
<div class="gl-display-flex" :data-testid="`stage-start-event-${index}`"> <div class="gl-display-flex" :data-testid="`stage-start-event-${index}`">
<span class="gl-m-0 gl-vertical-align-middle gl-mr-3 gl-font-weight-bold">{{ <span class="gl-m-0 gl-vertical-align-middle gl-mr-2 gl-font-weight-bold">{{
$options.I18N.DEFAULT_FIELD_START_EVENT_LABEL $options.I18N.DEFAULT_FIELD_START_EVENT_LABEL
}}</span> }}</span>
<gl-form-text>{{ stage.startEventIdentifier }}</gl-form-text> <gl-form-text>{{ eventName(stage.startEventIdentifier) }}</gl-form-text>
<gl-form-text v-if="stage.startEventLabel" <gl-form-text v-if="stage.startEventLabel"
>&nbsp;-&nbsp;{{ stage.startEventLabel }}</gl-form-text >&nbsp;-&nbsp;{{ stage.startEventLabel }}</gl-form-text
> >
</div> </div>
<div class="gl-display-flex" :data-testid="`stage-end-event-${index}`"> <div class="gl-display-flex" :data-testid="`stage-end-event-${index}`">
<span class="gl-m-0 gl-vertical-align-middle gl-mr-3 gl-font-weight-bold">{{ <span class="gl-m-0 gl-vertical-align-middle gl-mr-2 gl-font-weight-bold">{{
$options.I18N.DEFAULT_FIELD_START_EVENT_LABEL $options.I18N.DEFAULT_FIELD_END_EVENT_LABEL
}}</span> }}</span>
<gl-form-text>{{ stage.endEventIdentifier }}</gl-form-text> <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 v-if="stage.endEventLabel">&nbsp;-&nbsp;{{ stage.endEventLabel }}</gl-form-text>
</div> </div>
</div> </div>
......
import { isStartEvent, getAllowedEndEvents, eventToOption, eventsByIdentifier } from '../../utils'; import { isStartEvent, getAllowedEndEvents, eventToOption, eventsByIdentifier } from '../../utils';
import { I18N, ERRORS, defaultErrors, defaultFields } from './constants'; import { I18N, ERRORS, defaultErrors, defaultFields, NAME_MAX_LENGTH } from './constants';
import { DEFAULT_STAGE_NAMES } from '../../constants'; import { DEFAULT_STAGE_NAMES } from '../../constants';
/** /**
...@@ -82,13 +82,20 @@ export const initializeFormData = ({ fields, errors }) => { ...@@ -82,13 +82,20 @@ export const initializeFormData = ({ fields, errors }) => {
* @param {Object} fields key value pair of form field values * @param {Object} fields key value pair of form field values
* @returns {Object} key value pair of form fields with an array of errors * @returns {Object} key value pair of form fields with an array of errors
*/ */
export const validateFields = (fields) => { export const validateStage = (fields) => {
const newErrors = {}; const newErrors = {};
if (fields?.name) { if (fields?.name) {
newErrors.name = DEFAULT_STAGE_NAMES.includes(fields?.name.toLowerCase()) if (fields.name.length > NAME_MAX_LENGTH) {
? [ERRORS.STAGE_NAME_EXISTS] newErrors.name = [ERRORS.MAX_LENGTH];
: []; } else {
newErrors.name =
fields?.custom && DEFAULT_STAGE_NAMES.includes(fields.name.toLowerCase())
? [ERRORS.STAGE_NAME_EXISTS]
: [];
}
} else {
newErrors.name = [ERRORS.MIN_LENGTH];
} }
if (fields?.startEventIdentifier) { if (fields?.startEventIdentifier) {
...@@ -102,3 +109,21 @@ export const validateFields = (fields) => { ...@@ -102,3 +109,21 @@ export const validateFields = (fields) => {
} }
return newErrors; return newErrors;
}; };
/**
* Validates the name of a value stream Any errors will be
* returned as an array in a object with key`name`
*
* @param {Object} fields key value pair of form field values
* @returns {Object} key value pair of form fields with an array of errors
*/
export const validateValueStreamName = ({ name = '' }) => {
const errors = { name: [] };
if (name.length > NAME_MAX_LENGTH) {
errors.name.push(ERRORS.MAX_LENGTH);
}
if (!name.length) {
errors.name.push(ERRORS.MIN_LENGTH);
}
return errors;
};
...@@ -11,7 +11,7 @@ import { ...@@ -11,7 +11,7 @@ import {
} from '@gitlab/ui'; } from '@gitlab/ui';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import CustomStageFormFields from './create_value_stream_form/custom_stage_fields.vue'; import CustomStageFormFields from './create_value_stream_form/custom_stage_fields.vue';
import { validateFields, initializeFormData } from './create_value_stream_form/utils'; import { validateStage, initializeFormData } from './create_value_stream_form/utils';
import { defaultFields, ERRORS, I18N } from './create_value_stream_form/constants'; import { defaultFields, ERRORS, I18N } from './create_value_stream_form/constants';
import { STAGE_ACTIONS } from '../constants'; import { STAGE_ACTIONS } from '../constants';
import { getAllowedEndEvents, getLabelEventsIdentifiers, isLabelEvent } from '../utils'; import { getAllowedEndEvents, getLabelEventsIdentifiers, isLabelEvent } from '../utils';
...@@ -157,7 +157,8 @@ export default { ...@@ -157,7 +157,8 @@ export default {
}, },
handleUpdateFields(field, value) { handleUpdateFields(field, value) {
this.fields = { ...this.fields, [field]: value }; this.fields = { ...this.fields, [field]: value };
const newErrors = validateFields(this.fields);
const newErrors = validateStage({ ...this.fields, custom: true });
newErrors.endEventIdentifier = newErrors.endEventIdentifier =
this.fields.startEventIdentifier && this.eventMismatchError this.fields.startEventIdentifier && this.eventMismatchError
? [ERRORS.INVALID_EVENT_PAIRS] ? [ERRORS.INVALID_EVENT_PAIRS]
......
<script> <script>
import { GlForm, GlFormInput, GlFormGroup, GlModal } from '@gitlab/ui'; import Vue from 'vue';
import { GlButton, GlForm, GlFormInput, GlFormGroup, GlModal } from '@gitlab/ui';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { sprintf, __ } from '~/locale'; import { sprintf } from '~/locale';
import {
DEFAULT_STAGE_CONFIG,
STAGE_SORT_DIRECTION,
I18N,
} 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 { DATA_REFETCH_DELAY } from '../../shared/constants';
const ERRORS = { const swapArrayItems = (arr, left, right) => [
MIN_LENGTH: __('Name is required'), ...arr.slice(0, left),
MAX_LENGTH: __('Maximum length 100 characters'), arr[right],
}; arr[left],
...arr.slice(right + 1, arr.length),
const NAME_MAX_LENGTH = 100; ];
const validate = ({ name }) => { const findStageIndexByName = (stages, target = '') =>
const errors = { name: [] }; stages.findIndex(({ name }) => name === target);
if (name.length > NAME_MAX_LENGTH) {
errors.name.push(ERRORS.MAX_LENGTH);
}
if (!name.length) {
errors.name.push(ERRORS.MIN_LENGTH);
}
return errors;
};
const I18N = {
CREATE_VALUE_STREAM: __('Create Value Stream'),
CREATED: __("'%{name}' Value Stream created"),
CANCEL: __('Cancel'),
MODAL_TITLE: __('Value Stream Name'),
FIELD_NAME_LABEL: __('Name'),
FIELD_NAME_PLACEHOLDER: __('Example: My Value Stream'),
};
export default { export default {
name: 'ValueStreamForm', name: 'ValueStreamForm',
components: { components: {
GlButton,
GlForm, GlForm,
GlFormInput, GlFormInput,
GlFormGroup, GlFormGroup,
GlModal, GlModal,
DefaultStageFields,
}, },
props: { props: {
initialData: { initialData: {
...@@ -53,10 +46,18 @@ export default { ...@@ -53,10 +46,18 @@ export default {
}, },
}, },
data() { data() {
const { hasExtendedFormFields, initialData } = this;
const additionalFields = hasExtendedFormFields
? {
stages: DEFAULT_STAGE_CONFIG,
...initialData,
}
: { stages: [] };
return { return {
errors: {},
name: '', name: '',
...this.initialData, nameError: {},
stageErrors: [],
...additionalFields,
}; };
}, },
computed: { computed: {
...@@ -64,22 +65,26 @@ export default { ...@@ -64,22 +65,26 @@ export default {
initialFormErrors: 'createValueStreamErrors', initialFormErrors: 'createValueStreamErrors',
isCreating: 'isCreatingValueStream', isCreating: 'isCreatingValueStream',
}), }),
isValid() { ...mapState('customStages', ['formEvents']),
return !this.errors.name?.length; isValueStreamNameValid() {
return !this.nameError.name?.length;
}, },
invalidFeedback() { invalidFeedback() {
return this.errors.name?.join('\n'); return this.nameError.name?.join('\n');
}, },
hasFormErrors() { hasInitialFormErrors() {
const { initialFormErrors } = this; const { initialFormErrors } = this;
return Boolean(Object.keys(initialFormErrors).length); return Boolean(Object.keys(initialFormErrors).length);
}, },
isValid() {
return this.isValueStreamNameValid && !this.hasInitialFormErrors;
},
isLoading() { isLoading() {
return this.isCreating; return this.isCreating;
}, },
primaryProps() { primaryProps() {
return { return {
text: this.$options.I18N.CREATE_VALUE_STREAM, text: this.$options.I18N.FORM_TITLE,
attributes: [ attributes: [
{ variant: 'success' }, { variant: 'success' },
{ disabled: !this.isValid }, { disabled: !this.isValid },
...@@ -87,37 +92,93 @@ export default { ...@@ -87,37 +92,93 @@ export default {
], ],
}; };
}, },
hiddenStages() {
return this.stages.filter((stage) => stage.hidden);
},
activeStages() {
return this.stages.filter((stage) => !stage.hidden);
},
}, },
watch: { watch: {
initialFormErrors(newErrors = {}) { initialFormErrors(newErrors = {}) {
this.errors = newErrors; this.stageErrors = newErrors;
}, },
}, },
mounted() { mounted() {
const { initialFormErrors } = this; const { initialFormErrors } = this;
if (this.hasFormErrors) { if (this.hasInitialFormErrors) {
this.errors = initialFormErrors; this.stageErrors = initialFormErrors;
} else { } else {
this.onHandleInput(); this.validate();
} }
}, },
methods: { methods: {
...mapActions(['createValueStream']), ...mapActions(['createValueStream']),
onHandleInput: debounce(function debouncedValidation() { onUpdateValueStreamName: debounce(function debouncedValidation() {
const { name } = this; const { name } = this;
this.errors = validate({ name }); this.nameError = validateValueStreamName({ name });
}, DATA_REFETCH_DELAY), }, DATA_REFETCH_DELAY),
onSubmit() { onSubmit() {
const { name } = this; const { name, stages } = this;
return this.createValueStream({ name }).then(() => { return this.createValueStream({
if (!this.hasFormErrors) { name,
this.$toast.show(sprintf(this.$options.I18N.CREATED, { name }), { stages: stages.map(({ name: stageName, ...rest }) => ({
name: stageName,
...rest,
title: stageName,
})),
}).then(() => {
if (!this.hasInitialFormErrors) {
this.$toast.show(sprintf(this.$options.I18N.FORM_CREATED, { name }), {
position: 'top-center', position: 'top-center',
}); });
this.name = ''; this.name = '';
} }
}); });
}, },
stageGroupLabel(index) {
return sprintf(this.$options.I18N.STAGE_INDEX, { index: index + 1 });
},
recoverStageTitle(name) {
return sprintf(this.$options.I18N.HIDDEN_DEFAULT_STAGE, { name });
},
validateStages() {
return this.activeStages.map(validateStage);
},
validate() {
const { name } = this;
this.nameError = validateValueStreamName({ name });
this.stageErrors = this.validateStages();
},
handleMove({ index, direction }) {
const newStages =
direction === STAGE_SORT_DIRECTION.UP
? swapArrayItems(this.stages, index - 1, index)
: swapArrayItems(this.stages, index, index + 1);
Vue.set(this, 'stages', newStages);
},
validateStageFields(index) {
Vue.set(this.stageErrors, index, validateStage(this.activeStages[index]));
},
fieldErrors(index) {
return this.stageErrors[index];
},
onHide(index) {
const stage = this.stages[index];
Vue.set(this.stages, index, { ...stage, hidden: true });
},
onRestore(hiddenStageIndex) {
const stage = this.hiddenStages[hiddenStageIndex];
const stageIndex = findStageIndexByName(this.stages, stage.name);
Vue.set(this.stages, stageIndex, { ...stage, hidden: false });
},
handleReset() {
this.name = '';
DEFAULT_STAGE_CONFIG.forEach((stage, index) => {
Vue.set(this.stages, index, { ...stage, hidden: false });
});
},
}, },
I18N, I18N,
}; };
...@@ -126,29 +187,68 @@ export default { ...@@ -126,29 +187,68 @@ export default {
<gl-modal <gl-modal
data-testid="value-stream-form-modal" data-testid="value-stream-form-modal"
modal-id="value-stream-form-modal" modal-id="value-stream-form-modal"
:title="$options.I18N.MODAL_TITLE" scrollable
:title="$options.I18N.FORM_TITLE"
:action-primary="primaryProps" :action-primary="primaryProps"
:action-cancel="{ text: $options.I18N.CANCEL }" :action-cancel="{ text: $options.I18N.BTN_CANCEL }"
@primary.prevent="onSubmit" @primary.prevent="onSubmit"
> >
<gl-form> <gl-form>
<gl-form-group <gl-form-group
:label="$options.I18N.FIELD_NAME_LABEL"
label-for="create-value-stream-name" label-for="create-value-stream-name"
:label="$options.I18N.FORM_FIELD_NAME_LABEL"
:invalid-feedback="invalidFeedback" :invalid-feedback="invalidFeedback"
:state="isValid" :state="isValueStreamNameValid"
> >
<gl-form-input <div class="gl-display-flex gl-justify-content-space-between">
id="create-value-stream-name" <gl-form-input
v-model.trim="name" id="create-value-stream-name"
name="create-value-stream-name" v-model.trim="name"
:placeholder="$options.I18N.FIELD_NAME_PLACEHOLDER" name="create-value-stream-name"
:state="isValid" :placeholder="$options.I18N.FORM_FIELD_NAME_PLACEHOLDER"
required :state="isValueStreamNameValid"
@input="onHandleInput" required
/> @input="onUpdateValueStreamName"
/>
<gl-button
v-if="hiddenStages.length"
class="gl-ml-3"
variant="link"
@click="handleReset"
>{{ $options.I18N.RESTORE_DEFAULTS }}</gl-button
>
</div>
</gl-form-group> </gl-form-group>
<div v-if="hasExtendedFormFields" data-testid="extended-form-fields"></div> <div v-if="hasExtendedFormFields" data-testid="extended-form-fields">
<hr />
<div v-for="(stage, activeStageIndex) in activeStages" :key="activeStageIndex">
<span
class="gl-display-flex gl-m-0 gl-vertical-align-middle gl-mr-2 gl-font-weight-bold gl-display-flex"
>{{ stageGroupLabel(activeStageIndex) }}</span
>
<default-stage-fields
:stage="stage"
:stage-events="formEvents"
:index="activeStageIndex"
:total-stages="activeStages.length"
:errors="fieldErrors(activeStageIndex)"
@move="handleMove"
@hide="onHide"
@input="validateStageFields(activeStageIndex)"
/>
</div>
<div v-if="hiddenStages.length">
<hr />
<gl-form-group v-for="(stage, hiddenStageIndex) in hiddenStages" :key="stage.id">
<span class="gl-m-0 gl-vertical-align-middle gl-mr-3 gl-font-weight-bold">{{
recoverStageTitle(stage.name)
}}</span>
<gl-button variant="link" @click="onRestore(hiddenStageIndex)">{{
$options.I18N.RESTORE_HIDDEN_STAGE
}}</gl-button>
</gl-form-group>
</div>
</div>
</gl-form> </gl-form>
</gl-modal> </gl-modal>
</template> </template>
...@@ -34,6 +34,13 @@ export default { ...@@ -34,6 +34,13 @@ export default {
directives: { directives: {
GlModalDirective, GlModalDirective,
}, },
props: {
hasExtendedFormFields: {
type: Boolean,
required: false,
default: false,
},
},
computed: { computed: {
...mapState({ ...mapState({
isDeleting: 'isDeletingValueStream', isDeleting: 'isDeletingValueStream',
...@@ -116,7 +123,7 @@ export default { ...@@ -116,7 +123,7 @@ export default {
<gl-button v-else v-gl-modal-directive="'value-stream-form-modal'">{{ <gl-button v-else v-gl-modal-directive="'value-stream-form-modal'">{{
$options.I18N.CREATE_VALUE_STREAM $options.I18N.CREATE_VALUE_STREAM
}}</gl-button> }}</gl-button>
<value-stream-form /> <value-stream-form :has-extended-form-fields="hasExtendedFormFields" />
<gl-modal <gl-modal
data-testid="delete-value-stream-modal" data-testid="delete-value-stream-modal"
modal-id="delete-value-stream-modal" modal-id="delete-value-stream-modal"
......
...@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils'; ...@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import { GlFormGroup } from '@gitlab/ui'; import { GlFormGroup } from '@gitlab/ui';
import StageFieldActions from 'ee/analytics/cycle_analytics/components/create_value_stream_form/stage_field_actions.vue'; import StageFieldActions from 'ee/analytics/cycle_analytics/components/create_value_stream_form/stage_field_actions.vue';
import DefaultStageFields from 'ee/analytics/cycle_analytics/components/create_value_stream_form/default_stage_fields.vue'; import DefaultStageFields from 'ee/analytics/cycle_analytics/components/create_value_stream_form/default_stage_fields.vue';
import { customStageEvents as stageEvents } from '../../mock_data';
let wrapper = null; let wrapper = null;
...@@ -9,10 +10,12 @@ const defaultStageIndex = 0; ...@@ -9,10 +10,12 @@ const defaultStageIndex = 0;
const totalStages = 5; const totalStages = 5;
const stageNameError = 'Name is required'; const stageNameError = 'Name is required';
const defaultErrors = { name: [stageNameError] }; const defaultErrors = { name: [stageNameError] };
const ISSUE_CREATED = { id: 'issue_created', name: 'Issue created' };
const ISSUE_CLOSED = { id: 'issue_closed', name: 'Issue closed' };
const defaultStage = { const defaultStage = {
name: 'Cool new stage', name: 'Cool new stage',
startEventIdentifier: 'some_start_event', startEventIdentifier: [ISSUE_CREATED.id],
endEventIdentifier: 'some_end_event', endEventIdentifier: [ISSUE_CLOSED.id],
endEventLabel: 'some_label', endEventLabel: 'some_label',
}; };
...@@ -24,6 +27,7 @@ describe('DefaultStageFields', () => { ...@@ -24,6 +27,7 @@ describe('DefaultStageFields', () => {
totalStages, totalStages,
stage, stage,
errors, errors,
stageEvents,
}, },
stubs: { stubs: {
'labels-selector': false, 'labels-selector': false,
...@@ -54,12 +58,12 @@ describe('DefaultStageFields', () => { ...@@ -54,12 +58,12 @@ describe('DefaultStageFields', () => {
it('renders the field start event', () => { it('renders the field start event', () => {
expect(findStartEvent().exists()).toBe(true); expect(findStartEvent().exists()).toBe(true);
expect(findStartEvent().html()).toContain(defaultStage.startEventIdentifier); expect(findStartEvent().html()).toContain(ISSUE_CREATED.name);
}); });
it('renders the field end event', () => { it('renders the field end event', () => {
const content = findEndEvent().html(); const content = findEndEvent().html();
expect(content).toContain(defaultStage.endEventIdentifier); expect(content).toContain(ISSUE_CLOSED.name);
expect(content).toContain(defaultStage.endEventLabel); expect(content).toContain(defaultStage.endEventLabel);
}); });
......
import { initializeFormData } from 'ee/analytics/cycle_analytics/components/create_value_stream_form/utils'; import {
initializeFormData,
validateStage,
validateValueStreamName,
} from 'ee/analytics/cycle_analytics/components/create_value_stream_form/utils';
import {
ERRORS,
NAME_MAX_LENGTH,
} from 'ee/analytics/cycle_analytics/components/create_value_stream_form/constants';
import { emptyErrorsState, emptyState, formInitialData } from './mock_data'; import { emptyErrorsState, emptyState, formInitialData } from './mock_data';
describe('initializeFormData', () => { describe('initializeFormData', () => {
...@@ -71,3 +80,41 @@ describe('initializeFormData', () => { ...@@ -71,3 +80,41 @@ describe('initializeFormData', () => {
}); });
}); });
}); });
const expectFieldError = ({ error, field, result }) =>
expect(result).toMatchObject({ [field]: [error] });
describe('validateStage', () => {
const defaultFields = {
name: '',
startEventIdentifier: '',
endEventIdentifier: '',
custom: true,
};
it.each`
field | value | error | msg
${'name'} | ${'a'.repeat(NAME_MAX_LENGTH + 1)} | ${ERRORS.MAX_LENGTH} | ${'is too long'}
${'name'} | ${'issue'} | ${ERRORS.STAGE_NAME_EXISTS} | ${'is a lowercase default name'}
${'name'} | ${'Issue'} | ${ERRORS.STAGE_NAME_EXISTS} | ${'is a capitalized default name'}
${'endEventIdentifier'} | ${''} | ${ERRORS.START_EVENT_REQUIRED} | ${'has no corresponding start event'}
`('returns "$error" if $field $msg', ({ field, value, error }) => {
const result = validateStage({ ...defaultFields, [field]: value });
expectFieldError({ result, error, field });
});
});
describe('validateValueStreamName,', () => {
it('with valid data returns an empty array', () => {
expect(validateValueStreamName({ name: 'Cool stream name' })).toEqual({ name: [] });
});
it.each`
name | error | msg
${'a'.repeat(NAME_MAX_LENGTH + 1)} | ${ERRORS.MAX_LENGTH} | ${'too long'}
${''} | ${ERRORS.MIN_LENGTH} | ${'too short'}
`('returns "$error" if name is $msg', ({ name, error }) => {
const result = validateValueStreamName({ name });
expectFieldError({ result, error, field: 'name' });
});
});
import { GlModal, GlFormGroup } from '@gitlab/ui'; import { GlModal } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import ValueStreamForm from 'ee/analytics/cycle_analytics/components/value_stream_form.vue'; import ValueStreamForm from 'ee/analytics/cycle_analytics/components/value_stream_form.vue';
import { customStageEvents as formEvents } from '../mock_data';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -25,6 +26,14 @@ describe('ValueStreamForm', () => { ...@@ -25,6 +26,14 @@ describe('ValueStreamForm', () => {
actions: { actions: {
createValueStream: createValueStreamMock, createValueStream: createValueStreamMock,
}, },
modules: {
customStages: {
namespaced: true,
state: {
formEvents,
},
},
},
}); });
const createComponent = ({ props = {}, data = {}, initialState = {} } = {}) => const createComponent = ({ props = {}, data = {}, initialState = {} } = {}) =>
...@@ -50,7 +59,6 @@ describe('ValueStreamForm', () => { ...@@ -50,7 +59,6 @@ describe('ValueStreamForm', () => {
const createSubmitButtonDisabledState = () => const createSubmitButtonDisabledState = () =>
findModal().props('actionPrimary').attributes[1].disabled; findModal().props('actionPrimary').attributes[1].disabled;
const submitModal = () => findModal().vm.$emit('primary', mockEvent); const submitModal = () => findModal().vm.$emit('primary', mockEvent);
const findFormGroup = () => wrapper.find(GlFormGroup);
const findExtendedFormFields = () => wrapper.find('[data-testid="extended-form-fields"]'); const findExtendedFormFields = () => wrapper.find('[data-testid="extended-form-fields"]');
afterEach(() => { afterEach(() => {
...@@ -92,12 +100,6 @@ describe('ValueStreamForm', () => { ...@@ -92,12 +100,6 @@ describe('ValueStreamForm', () => {
}); });
}); });
it('renders the error', () => {
expect(findFormGroup().attributes('invalid-feedback')).toEqual(
createValueStreamErrors.name.join('\n'),
);
});
it('submit button is disabled', () => { it('submit button is disabled', () => {
expect(createSubmitButtonDisabledState()).toBe(true); expect(createSubmitButtonDisabledState()).toBe(true);
}); });
...@@ -120,6 +122,7 @@ describe('ValueStreamForm', () => { ...@@ -120,6 +122,7 @@ describe('ValueStreamForm', () => {
it('calls the "createValueStream" event when submitted', () => { it('calls the "createValueStream" event when submitted', () => {
expect(createValueStreamMock).toHaveBeenCalledWith(expect.any(Object), { expect(createValueStreamMock).toHaveBeenCalledWith(expect.any(Object), {
name: streamName, name: streamName,
stages: [],
}); });
}); });
......
...@@ -8249,6 +8249,9 @@ msgstr "" ...@@ -8249,6 +8249,9 @@ msgstr ""
msgid "CreateValueStreamForm|%{name} (default)" msgid "CreateValueStreamForm|%{name} (default)"
msgstr "" msgstr ""
msgid "CreateValueStreamForm|'%{name}' Value Stream created"
msgstr ""
msgid "CreateValueStreamForm|Add stage" msgid "CreateValueStreamForm|Add stage"
msgstr "" msgstr ""
...@@ -8294,6 +8297,9 @@ msgstr "" ...@@ -8294,6 +8297,9 @@ msgstr ""
msgid "CreateValueStreamForm|Recover hidden stage" msgid "CreateValueStreamForm|Recover hidden stage"
msgstr "" msgstr ""
msgid "CreateValueStreamForm|Restore defaults"
msgstr ""
msgid "CreateValueStreamForm|Restore stage" msgid "CreateValueStreamForm|Restore stage"
msgstr "" msgstr ""
...@@ -11553,9 +11559,6 @@ msgstr "" ...@@ -11553,9 +11559,6 @@ msgstr ""
msgid "Example: @sub\\.company\\.com$" msgid "Example: @sub\\.company\\.com$"
msgstr "" msgstr ""
msgid "Example: My Value Stream"
msgstr ""
msgid "Example: Usage = single query. (Requested) / (Capacity) = multiple queries combined into a formula." msgid "Example: Usage = single query. (Requested) / (Capacity) = multiple queries combined into a formula."
msgstr "" msgstr ""
...@@ -17223,9 +17226,6 @@ msgstr "" ...@@ -17223,9 +17226,6 @@ msgstr ""
msgid "Maximum job timeout has a value which could not be accepted" msgid "Maximum job timeout has a value which could not be accepted"
msgstr "" msgstr ""
msgid "Maximum length 100 characters"
msgstr ""
msgid "Maximum lifetime allowable for Personal Access Tokens is active, your expire date must be set before %{maximum_allowable_date}." msgid "Maximum lifetime allowable for Personal Access Tokens is active, your expire date must be set before %{maximum_allowable_date}."
msgstr "" msgstr ""
...@@ -18457,9 +18457,6 @@ msgstr "" ...@@ -18457,9 +18457,6 @@ msgstr ""
msgid "Name has already been taken" msgid "Name has already been taken"
msgstr "" msgstr ""
msgid "Name is required"
msgstr ""
msgid "Name new label" msgid "Name new label"
msgstr "" msgstr ""
...@@ -30893,9 +30890,6 @@ msgstr "" ...@@ -30893,9 +30890,6 @@ msgstr ""
msgid "Value Stream Analytics gives an overview of how much time it takes to go from idea to production in your project." msgid "Value Stream Analytics gives an overview of how much time it takes to go from idea to production in your project."
msgstr "" msgstr ""
msgid "Value Stream Name"
msgstr ""
msgid "ValueStreamAnalyticsStage|We don't have enough data to show this stage." msgid "ValueStreamAnalyticsStage|We don't have enough data to show this stage."
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment