diff --git a/ee/app/assets/javascripts/analytics/cycle_analytics/components/create_value_stream_form/constants.js b/ee/app/assets/javascripts/analytics/cycle_analytics/components/create_value_stream_form/constants.js index f6b51a7e7dd282367a8d41dbfdf758dda25bbfc9..02f3de2153ab4e9826b17c02c6a04bc1937aa112 100644 --- a/ee/app/assets/javascripts/analytics/cycle_analytics/components/create_value_stream_form/constants.js +++ b/ee/app/assets/javascripts/analytics/cycle_analytics/components/create_value_stream_form/constants.js @@ -1,32 +1,52 @@ -import { __, s__ } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; + +export const NAME_MAX_LENGTH = 100; export const I18N = { - RECOVER_HIDDEN_STAGE: s__('CustomCycleAnalytics|Recover hidden stage'), - RECOVER_STAGE_TITLE: s__('CustomCycleAnalytics|Default stages'), - RECOVER_STAGES_VISIBLE: s__('CustomCycleAnalytics|All default stages are currently visible'), - SELECT_START_EVENT: s__('CustomCycleAnalytics|Select start event'), - SELECT_END_EVENT: s__('CustomCycleAnalytics|Select end event'), - FORM_FIELD_NAME: s__('CustomCycleAnalytics|Name'), - FORM_FIELD_NAME_PLACEHOLDER: s__('CustomCycleAnalytics|Enter a name for the stage'), - FORM_FIELD_START_EVENT: s__('CustomCycleAnalytics|Start event'), - FORM_FIELD_START_EVENT_LABEL: s__('CustomCycleAnalytics|Start event label'), - FORM_FIELD_END_EVENT: s__('CustomCycleAnalytics|End event'), - FORM_FIELD_END_EVENT_LABEL: s__('CustomCycleAnalytics|End event label'), - BTN_UPDATE_STAGE: s__('CustomCycleAnalytics|Update stage'), - BTN_ADD_STAGE: s__('CustomCycleAnalytics|Add stage'), - TITLE_EDIT_STAGE: s__('CustomCycleAnalytics|Editing stage'), - TITLE_ADD_STAGE: s__('CustomCycleAnalytics|New stage'), + FORM_TITLE: __('Create Value Stream'), + FORM_CREATED: __("'%{name}' Value Stream created"), + RECOVER_HIDDEN_STAGE: s__('CreateValueStreamForm|Recover hidden stage'), + RESTORE_HIDDEN_STAGE: s__('CreateValueStreamForm|Restore stage'), + RECOVER_STAGE_TITLE: s__('CreateValueStreamForm|Default stages'), + 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_START_EVENT: s__('CreateValueStreamForm|Start event'), + FORM_FIELD_START_EVENT_LABEL: s__('CreateValueStreamForm|Start event label'), + FORM_FIELD_END_EVENT: s__('CreateValueStreamForm|End event'), + FORM_FIELD_END_EVENT_LABEL: s__('CreateValueStreamForm|End event label'), + DEFAULT_FIELD_START_EVENT_LABEL: s__('CreateValueStreamForm|Start event: '), + DEFAULT_FIELD_END_EVENT_LABEL: s__('CreateValueStreamForm|End event: '), + BTN_UPDATE_STAGE: s__('CreateValueStreamForm|Update stage'), + BTN_ADD_STAGE: s__('CreateValueStreamForm|Add 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)'), }; export const ERRORS = { - START_EVENT_REQUIRED: s__('CustomCycleAnalytics|Please select a start event first'), - STAGE_NAME_EXISTS: s__('CustomCycleAnalytics|Stage name already exists'), + MIN_LENGTH: s__('CreateValueStreamForm|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'), + STAGE_NAME_EXISTS: s__('CreateValueStreamForm|Stage name already exists'), INVALID_EVENT_PAIRS: s__( - 'CustomCycleAnalytics|Start event changed, please select a valid end event', + 'CreateValueStreamForm|Start event changed, please select a valid end event', ), }; +export const STAGE_SORT_DIRECTION = { + UP: 'UP', + DOWN: 'DOWN', +}; + export const defaultErrors = { id: [], name: [], @@ -44,3 +64,16 @@ export const defaultFields = { endEventIdentifier: null, endEventLabelId: null, }; + +export const DEFAULT_STAGE_CONFIG = ['issue', 'plan', 'code', 'test', 'review', 'staging'].map( + id => ({ + id, + name: capitalizeFirstCharacter(id), + startEventIdentifier: null, + endEventIdentifier: null, + startEventLabel: null, + endEventLabel: null, + custom: false, + hidden: false, + }), +); diff --git a/ee/app/assets/javascripts/analytics/cycle_analytics/components/create_value_stream_form/custom_stage_fields.vue b/ee/app/assets/javascripts/analytics/cycle_analytics/components/create_value_stream_form/custom_stage_fields.vue index adcb22e672c0aab03c86bf2cce729df3f998b04c..aa96e66c26eb08ca1b303489e70a721e11b330c7 100644 --- a/ee/app/assets/javascripts/analytics/cycle_analytics/components/create_value_stream_form/custom_stage_fields.vue +++ b/ee/app/assets/javascripts/analytics/cycle_analytics/components/create_value_stream_form/custom_stage_fields.vue @@ -69,7 +69,7 @@ export default { <template> <div> <gl-form-group - :label="$options.I18N.FORM_FIELD_NAME" + :label="$options.I18N.FORM_FIELD_NAME_LABEL" label-for="custom-stage-name" :state="hasFieldErrors('name')" :invalid-feedback="fieldErrorMessage('name')" diff --git a/ee/app/assets/javascripts/analytics/cycle_analytics/components/create_value_stream_form/default_stage_fields.vue b/ee/app/assets/javascripts/analytics/cycle_analytics/components/create_value_stream_form/default_stage_fields.vue new file mode 100644 index 0000000000000000000000000000000000000000..24d5e47a3487b7d96845d5221ee21d7a92a2dd6c --- /dev/null +++ b/ee/app/assets/javascripts/analytics/cycle_analytics/components/create_value_stream_form/default_stage_fields.vue @@ -0,0 +1,84 @@ +<script> +import { GlFormGroup, GlFormInput, GlFormText } from '@gitlab/ui'; +import StageFieldActions from './stage_field_actions.vue'; +import { I18N } from './constants'; + +export default { + name: 'DefaultStageFields', + components: { + StageFieldActions, + GlFormGroup, + GlFormInput, + GlFormText, + }, + props: { + index: { + type: Number, + required: true, + }, + totalStages: { + type: Number, + required: true, + }, + stage: { + type: Object, + required: true, + }, + errors: { + type: Object, + required: false, + default: () => {}, + }, + }, + methods: { + isValid(field) { + return !this.errors[field]?.length; + }, + renderError(field) { + return this.errors[field]?.join('\n'); + }, + }, + I18N, +}; +</script> +<template> + <div class="gl-mb-4"> + <div class="gl-display-flex"> + <gl-form-group + class="gl-flex-grow-1 gl-mb-0" + :state="isValid('name')" + :invalid-feedback="renderError('name')" + > + <gl-form-input + v-model.trim="stage.name" + :name="`create-value-stream-stage-${index}`" + :placeholder="$options.I18N.FIELD_STAGE_NAME_PLACEHOLDER" + required + @input="$emit('input', $event)" + /> + </gl-form-group> + <stage-field-actions + :index="index" + :stage-count="totalStages" + @move="$emit('move', $event)" + @hide="$emit('hide', $event)" + /> + </div> + <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">{{ + $options.I18N.DEFAULT_FIELD_START_EVENT_LABEL + }}</span> + <gl-form-text>{{ stage.startEventIdentifier }}</gl-form-text> + <gl-form-text v-if="stage.startEventLabel" + > - {{ stage.startEventLabel }}</gl-form-text + > + </div> + <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">{{ + $options.I18N.DEFAULT_FIELD_START_EVENT_LABEL + }}</span> + <gl-form-text>{{ stage.endEventIdentifier }}</gl-form-text> + <gl-form-text v-if="stage.endEventLabel"> - {{ stage.endEventLabel }}</gl-form-text> + </div> + </div> +</template> diff --git a/ee/app/assets/javascripts/analytics/cycle_analytics/components/create_value_stream_form/stage_field_actions.vue b/ee/app/assets/javascripts/analytics/cycle_analytics/components/create_value_stream_form/stage_field_actions.vue new file mode 100644 index 0000000000000000000000000000000000000000..8272b2b7c13d216fe0a01f8a9fcbc286671b332d --- /dev/null +++ b/ee/app/assets/javascripts/analytics/cycle_analytics/components/create_value_stream_form/stage_field_actions.vue @@ -0,0 +1,57 @@ +<script> +import { GlButton, GlButtonGroup } from '@gitlab/ui'; +import { STAGE_SORT_DIRECTION } from './constants'; + +export default { + name: 'StageFieldActions', + components: { + GlButton, + GlButtonGroup, + }, + props: { + index: { + type: Number, + required: true, + }, + stageCount: { + type: Number, + required: true, + }, + }, + computed: { + lastStageIndex() { + return this.stageCount - 1; + }, + isFirstActiveStage() { + return this.index === 0; + }, + isLastActiveStage() { + return this.index === this.lastStageIndex; + }, + }, + STAGE_SORT_DIRECTION, +}; +</script> +<template> + <div> + <gl-button-group class="gl-px-2"> + <gl-button + :data-testid="`stage-action-move-down-${index}`" + :disabled="isLastActiveStage" + icon="arrow-down" + @click="$emit('move', { index, direction: $options.STAGE_SORT_DIRECTION.DOWN })" + /> + <gl-button + :data-testid="`stage-action-move-up-${index}`" + :disabled="isFirstActiveStage" + icon="arrow-up" + @click="$emit('move', { index, direction: $options.STAGE_SORT_DIRECTION.UP })" + /> + </gl-button-group> + <gl-button + :data-testid="`stage-action-hide-${index}`" + icon="archive" + @click="$emit('hide', index)" + /> + </div> +</template> diff --git a/ee/spec/frontend/analytics/cycle_analytics/components/create_value_stream_form/default_stage_fields_spec.js b/ee/spec/frontend/analytics/cycle_analytics/components/create_value_stream_form/default_stage_fields_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..b6e32848de710a331a62686aa1e219120c300810 --- /dev/null +++ b/ee/spec/frontend/analytics/cycle_analytics/components/create_value_stream_form/default_stage_fields_spec.js @@ -0,0 +1,105 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlFormGroup } from '@gitlab/ui'; +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'; + +let wrapper = null; + +const defaultStageIndex = 0; +const totalStages = 5; +const stageNameError = 'Name is required'; +const defaultErrors = { name: [stageNameError] }; +const defaultStage = { + name: 'Cool new stage', + startEventIdentifier: 'some_start_event', + endEventIdentifier: 'some_end_event', + endEventLabel: 'some_label', +}; + +describe('DefaultStageFields', () => { + function createComponent({ stage = defaultStage, errors = {} } = {}) { + return shallowMount(DefaultStageFields, { + propsData: { + index: defaultStageIndex, + totalStages, + stage, + errors, + }, + stubs: { + 'labels-selector': false, + 'gl-form-text': false, + }, + }); + } + + const findStageFieldName = () => wrapper.find('[name="create-value-stream-stage-0"]'); + const findStartEvent = () => wrapper.find('[data-testid="stage-start-event-0"]'); + const findEndEvent = () => wrapper.find('[data-testid="stage-end-event-0"]'); + const findFormGroup = () => wrapper.find(GlFormGroup); + const findFieldActions = () => wrapper.find(StageFieldActions); + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders the stage field name', () => { + expect(findStageFieldName().exists()).toBe(true); + expect(findStageFieldName().html()).toContain(defaultStage.name); + }); + + it('renders the field start event', () => { + expect(findStartEvent().exists()).toBe(true); + expect(findStartEvent().html()).toContain(defaultStage.startEventIdentifier); + }); + + it('renders the field end event', () => { + const content = findEndEvent().html(); + expect(content).toContain(defaultStage.endEventIdentifier); + expect(content).toContain(defaultStage.endEventLabel); + }); + + it('renders an event label if it exists', () => { + const content = findEndEvent().html(); + expect(content).toContain(defaultStage.endEventLabel); + }); + + it('on field input emits an input event', () => { + expect(wrapper.emitted('input')).toBeUndefined(); + + const newInput = 'coooool'; + findStageFieldName().vm.$emit('input', newInput); + expect(wrapper.emitted('input')[0]).toEqual([newInput]); + }); + + describe('StageFieldActions', () => { + it('when the stage is hidden emits a `hide` event', () => { + expect(wrapper.emitted('hide')).toBeUndefined(); + + const stageMoveParams = { index: defaultStageIndex, direction: 'UP' }; + findFieldActions().vm.$emit('move', stageMoveParams); + expect(wrapper.emitted('move')[0]).toEqual([stageMoveParams]); + }); + + it('when the stage is moved emits a `move` event', () => { + expect(wrapper.emitted('move')).toBeUndefined(); + + findFieldActions().vm.$emit('move', defaultStageIndex); + expect(wrapper.emitted('move')[0]).toEqual([defaultStageIndex]); + }); + }); + + describe('with field errors', () => { + beforeEach(() => { + wrapper = createComponent({ errors: defaultErrors }); + }); + + it('displays the field error', () => { + expect(findFormGroup().html()).toContain(stageNameError); + }); + }); +}); diff --git a/ee/spec/frontend/analytics/cycle_analytics/components/create_value_stream_form/stage_field_actions_spec.js b/ee/spec/frontend/analytics/cycle_analytics/components/create_value_stream_form/stage_field_actions_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a118e2c2aa5b4dbea5be458289905d234e6e7fe5 --- /dev/null +++ b/ee/spec/frontend/analytics/cycle_analytics/components/create_value_stream_form/stage_field_actions_spec.js @@ -0,0 +1,71 @@ +import { shallowMount } from '@vue/test-utils'; +import StageFieldActions from 'ee/analytics/cycle_analytics/components/create_value_stream_form/stage_field_actions.vue'; + +const defaultIndex = 0; +const stageCount = 3; + +describe('StageFieldActions', () => { + function createComponent({ index = defaultIndex }) { + return shallowMount(StageFieldActions, { + propsData: { + index, + stageCount, + }, + }); + } + + 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"]'); + + beforeEach(() => { + wrapper = createComponent({}); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('will render the move up action', () => { + expect(findMoveUpBtn().exists()).toBe(true); + }); + + it('will render the move down action', () => { + expect(findMoveDownBtn().exists()).toBe(true); + }); + + it('will render the hide action', () => { + expect(findHideBtn().exists()).toBe(true); + }); + + it('disables the move up button', () => { + expect(findMoveUpBtn().props('disabled')).toBe(true); + }); + + it('when the down button is clicked will emit a `move` event', () => { + findMoveDownBtn().vm.$emit('click'); + expect(wrapper.emitted('move')[0]).toEqual([{ direction: 'DOWN', index: 0 }]); + }); + + it('when the up button is clicked will emit a `move` event', () => { + findMoveUpBtn().vm.$emit('click'); + expect(wrapper.emitted('move')[0]).toEqual([{ direction: 'UP', index: 0 }]); + }); + + it('when the hide button is clicked will emit a `move` event', () => { + findHideBtn().vm.$emit('click'); + expect(wrapper.emitted('hide')[0]).toEqual([0]); + }); + + describe('when the current index is the same as the total number of stages', () => { + beforeEach(() => { + wrapper = createComponent({ index: 2 }); + }); + + it('disables the move down button', () => { + expect(findMoveDownBtn().props('disabled')).toBe(true); + }); + }); +}); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 48eb4cc2812d4626a234bd3977f98cfa8a2dd862..8ef548abb9b842a5ba99e3836f5b25a8e5a662b6 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8180,6 +8180,84 @@ msgstr "" msgid "CreateTokenToCloneLink|create a personal access token" msgstr "" +msgid "CreateValueStreamForm|%{name} (default)" +msgstr "" + +msgid "CreateValueStreamForm|Add stage" +msgstr "" + +msgid "CreateValueStreamForm|All default stages are currently visible" +msgstr "" + +msgid "CreateValueStreamForm|Default stages" +msgstr "" + +msgid "CreateValueStreamForm|Editing stage" +msgstr "" + +msgid "CreateValueStreamForm|End event" +msgstr "" + +msgid "CreateValueStreamForm|End event label" +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" +msgstr "" + +msgid "CreateValueStreamForm|Name" +msgstr "" + +msgid "CreateValueStreamForm|Name is required" +msgstr "" + +msgid "CreateValueStreamForm|New stage" +msgstr "" + +msgid "CreateValueStreamForm|Please select a start event first" +msgstr "" + +msgid "CreateValueStreamForm|Recover hidden stage" +msgstr "" + +msgid "CreateValueStreamForm|Restore stage" +msgstr "" + +msgid "CreateValueStreamForm|Select end event" +msgstr "" + +msgid "CreateValueStreamForm|Select start event" +msgstr "" + +msgid "CreateValueStreamForm|Stage %{index}" +msgstr "" + +msgid "CreateValueStreamForm|Stage name already exists" +msgstr "" + +msgid "CreateValueStreamForm|Start event" +msgstr "" + +msgid "CreateValueStreamForm|Start event changed, please select a valid end event" +msgstr "" + +msgid "CreateValueStreamForm|Start event label" +msgstr "" + +msgid "CreateValueStreamForm|Start event: " +msgstr "" + +msgid "CreateValueStreamForm|Update stage" +msgstr "" + msgid "Created" msgstr "" @@ -8357,57 +8435,21 @@ msgstr "" msgid "CustomCycleAnalytics|Add stage" msgstr "" -msgid "CustomCycleAnalytics|All default stages are currently visible" -msgstr "" - -msgid "CustomCycleAnalytics|Default stages" -msgstr "" - msgid "CustomCycleAnalytics|Editing stage" msgstr "" -msgid "CustomCycleAnalytics|End event" -msgstr "" - msgid "CustomCycleAnalytics|End event label" msgstr "" -msgid "CustomCycleAnalytics|Enter a name for the stage" -msgstr "" - -msgid "CustomCycleAnalytics|Name" -msgstr "" - msgid "CustomCycleAnalytics|New stage" msgstr "" -msgid "CustomCycleAnalytics|Please select a start event first" -msgstr "" - -msgid "CustomCycleAnalytics|Recover hidden stage" -msgstr "" - -msgid "CustomCycleAnalytics|Select end event" -msgstr "" - -msgid "CustomCycleAnalytics|Select start event" -msgstr "" - msgid "CustomCycleAnalytics|Stage name already exists" msgstr "" -msgid "CustomCycleAnalytics|Start event" -msgstr "" - -msgid "CustomCycleAnalytics|Start event changed, please select a valid end event" -msgstr "" - msgid "CustomCycleAnalytics|Start event label" msgstr "" -msgid "CustomCycleAnalytics|Update stage" -msgstr "" - msgid "Customer Portal" msgstr ""