Commit 26d4d5b3 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '267536-extract-custom-stage-fields' into 'master'

Extract custom value stream form fields

See merge request gitlab-org/gitlab!50020
parents bd0dab96 166193b5
import { __, s__ } from '~/locale';
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'),
BTN_CANCEL: __('Cancel'),
};
export const ERRORS = {
START_EVENT_REQUIRED: s__('CustomCycleAnalytics|Please select a start event first'),
STAGE_NAME_EXISTS: s__('CustomCycleAnalytics|Stage name already exists'),
INVALID_EVENT_PAIRS: s__(
'CustomCycleAnalytics|Start event changed, please select a valid end event',
),
};
export const defaultErrors = {
id: [],
name: [],
startEventIdentifier: [],
startEventLabelId: [],
endEventIdentifier: [],
endEventLabelId: [],
};
export const defaultFields = {
id: null,
name: null,
startEventIdentifier: null,
startEventLabelId: null,
endEventIdentifier: null,
endEventLabelId: null,
};
<script>
import { GlFormGroup, GlFormInput, GlFormSelect } from '@gitlab/ui';
import LabelsSelector from '../labels_selector.vue';
import { I18N } from './constants';
import { startEventOptions, endEventOptions } from './utils';
import { isLabelEvent } from '../../utils';
export default {
name: 'CustomStageFormFields',
components: {
GlFormGroup,
GlFormInput,
GlFormSelect,
LabelsSelector,
},
props: {
fields: {
type: Object,
required: true,
},
errors: {
type: Object,
required: false,
default: () => {},
},
events: {
type: Array,
required: true,
},
labelEvents: {
type: Array,
required: true,
},
},
computed: {
startEvents() {
return startEventOptions(this.events);
},
endEvents() {
return endEventOptions(this.events, this.fields.startEventIdentifier);
},
hasStartEvent() {
return this.fields.startEventIdentifier;
},
startEventRequiresLabel() {
return isLabelEvent(this.labelEvents, this.fields.startEventIdentifier);
},
endEventRequiresLabel() {
return isLabelEvent(this.labelEvents, this.fields.endEventIdentifier);
},
},
methods: {
eventFieldClasses(condition) {
return condition ? 'gl-w-half gl-mr-2' : 'gl-w-full';
},
hasFieldErrors(key) {
return this.errors[key]?.length < 1;
},
fieldErrorMessage(key) {
return this.errors[key]?.join('\n');
},
handleUpdateField(field, value) {
this.$emit('update', field, value);
},
},
I18N,
};
</script>
<template>
<div>
<gl-form-group
:label="$options.I18N.FORM_FIELD_NAME"
label-for="custom-stage-name"
:state="hasFieldErrors('name')"
:invalid-feedback="fieldErrorMessage('name')"
data-testid="custom-stage-name"
>
<gl-form-input
:value="fields.name"
class="form-control"
type="text"
name="custom-stage-name"
:placeholder="$options.I18N.FORM_FIELD_NAME_PLACEHOLDER"
required
@input="handleUpdateField('name', $event)"
/>
</gl-form-group>
<div class="d-flex" :class="{ 'gl-justify-content-between': startEventRequiresLabel }">
<div :class="eventFieldClasses(startEventRequiresLabel)">
<gl-form-group
data-testid="custom-stage-start-event"
:label="$options.I18N.FORM_FIELD_START_EVENT"
label-for="custom-stage-start-event"
:state="hasFieldErrors('startEventIdentifier')"
:invalid-feedback="fieldErrorMessage('startEventIdentifier')"
>
<gl-form-select
v-model="fields.startEventIdentifier"
name="custom-stage-start-event"
:required="true"
:options="startEvents"
@input="handleUpdateField('startEventIdentifier', $event)"
/>
</gl-form-group>
</div>
<div v-if="startEventRequiresLabel" class="gl-w-half gl-ml-2">
<gl-form-group
data-testid="custom-stage-start-event-label"
:label="$options.I18N.FORM_FIELD_START_EVENT_LABEL"
label-for="custom-stage-start-event-label"
:state="hasFieldErrors('startEventLabelId')"
:invalid-feedback="fieldErrorMessage('startEventLabelId')"
>
<labels-selector
:selected-label-id="[fields.startEventLabelId]"
name="custom-stage-start-event-label"
@selectLabel="handleUpdateField('startEventLabelId', $event)"
/>
</gl-form-group>
</div>
</div>
<div class="d-flex" :class="{ 'gl-justify-content-between': endEventRequiresLabel }">
<div :class="eventFieldClasses(endEventRequiresLabel)">
<gl-form-group
data-testid="custom-stage-end-event"
:label="$options.I18N.FORM_FIELD_END_EVENT"
label-for="custom-stage-end-event"
:state="hasFieldErrors('endEventIdentifier')"
:invalid-feedback="fieldErrorMessage('endEventIdentifier')"
>
<gl-form-select
v-model="fields.endEventIdentifier"
name="custom-stage-end-event"
:options="endEvents"
:required="true"
:disabled="!hasStartEvent"
@input="handleUpdateField('endEventIdentifier', $event)"
/>
</gl-form-group>
</div>
<div v-if="endEventRequiresLabel" class="w-50 ml-1">
<gl-form-group
data-testid="custom-stage-end-event-label"
:label="$options.I18N.FORM_FIELD_END_EVENT_LABEL"
label-for="custom-stage-end-event-label"
:state="hasFieldErrors('endEventLabelId')"
:invalid-feedback="fieldErrorMessage('endEventLabelId')"
>
<labels-selector
:selected-label-id="[fields.endEventLabelId]"
name="custom-stage-end-event-label"
@selectLabel="handleUpdateField('endEventLabelId', $event)"
/>
</gl-form-group>
</div>
</div>
</div>
</template>
import { isStartEvent, getAllowedEndEvents, eventToOption, eventsByIdentifier } from '../../utils';
import { I18N, ERRORS, defaultErrors, defaultFields } from './constants';
import { DEFAULT_STAGE_NAMES } from '../../constants';
/**
* @typedef {Object} CustomStageEvents
* @property {String} canBeStartEvent - Title of the metric measured
* @property {String} name - Friendly name for the event
* @property {String} identifier - snakeized name for the event
*
* @typedef {Object} DropdownData
* @property {String} text - Friendly name for the event
* @property {String} value - Value to be submitted for the dropdown
*/
/**
* Takes an array of custom stage events to return only the
* events where `canBeStartEvent` is true and converts them
* to { value, text } pairs for use in dropdowns
*
* @param {CustomStageEvents[]} events
* @returns {DropdownData[]} array of start events formatted for dropdowns
*/
export const startEventOptions = eventsList => [
{ value: null, text: I18N.SELECT_START_EVENT },
...eventsList.filter(isStartEvent).map(eventToOption),
];
/**
* Takes an array of custom stage events to return only the
* events where `canBeStartEvent` is false and converts them
* to { value, text } pairs for use in dropdowns
*
* @param {CustomStageEvents[]} events
* @returns {DropdownData[]} array end events formatted for dropdowns
*/
export const endEventOptions = (eventsList, startEventIdentifier) => {
const endEvents = getAllowedEndEvents(eventsList, startEventIdentifier);
return [
{ value: null, text: I18N.SELECT_END_EVENT },
...eventsByIdentifier(eventsList, endEvents).map(eventToOption),
];
};
/**
* @typedef {Object} CustomStageFormData
* @property {Object.<String, String>} fields - form field values
* @property {Object.<String, Array>} errors - form field errors
*/
/**
* Initializes the fields and errors for the custom stages form
* providing defaults for any missing keys
*
* @param {CustomStageFormData} data
* @returns {CustomStageFormData} the updated initial data with all defaults
*/
export const initializeFormData = ({ fields, errors }) => {
const initErrors = fields?.endEventIdentifier
? defaultErrors
: {
...defaultErrors,
endEventIdentifier: !fields?.startEventIdentifier ? [ERRORS.START_EVENT_REQUIRED] : [],
};
return {
fields: {
...defaultFields,
...fields,
},
errors: {
...initErrors,
...errors,
},
};
};
/**
* Validates the form fields for the custom stages form
* Any errors will be returned in a object where the key is
* the name of the field.g
*
* @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 validateFields = fields => {
const newErrors = {};
if (fields?.name) {
newErrors.name = DEFAULT_STAGE_NAMES.includes(fields?.name.toLowerCase())
? [ERRORS.STAGE_NAME_EXISTS]
: [];
}
if (fields?.startEventIdentifier) {
newErrors.endEventIdentifier = [];
} else {
newErrors.endEventIdentifier = [ERRORS.START_EVENT_REQUIRED];
}
if (fields?.startEventIdentifier && fields?.endEventIdentifier) {
newErrors.endEventIdentifier = [];
}
return newErrors;
};
......@@ -2,9 +2,6 @@
import { mapGetters, mapState } from 'vuex';
import { isEqual } from 'lodash';
import {
GlFormGroup,
GlFormInput,
GlFormSelect,
GlLoadingIcon,
GlDropdown,
GlDropdownSectionHeader,
......@@ -12,76 +9,22 @@ import {
GlSprintf,
GlButton,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import LabelsSelector from './labels_selector.vue';
import { STAGE_ACTIONS, DEFAULT_STAGE_NAMES } from '../constants';
import {
isStartEvent,
isLabelEvent,
getAllowedEndEvents,
eventToOption,
eventsByIdentifier,
getLabelEventsIdentifiers,
} from '../utils';
const defaultFields = {
id: null,
name: null,
startEventIdentifier: null,
startEventLabelId: null,
endEventIdentifier: null,
endEventLabelId: null,
};
const defaultErrors = {
id: [],
name: [],
startEventIdentifier: [],
startEventLabelId: [],
endEventIdentifier: [],
endEventLabelId: [],
};
const ERRORS = {
START_EVENT_REQUIRED: s__('CustomCycleAnalytics|Please select a start event first'),
STAGE_NAME_EXISTS: s__('CustomCycleAnalytics|Stage name already exists'),
INVALID_EVENT_PAIRS: s__(
'CustomCycleAnalytics|Start event changed, please select a valid stop event',
),
};
export const initializeFormData = ({ emptyFieldState = defaultFields, fields, errors }) => {
const initErrors = fields?.endEventIdentifier
? defaultErrors
: {
...defaultErrors,
endEventIdentifier: !fields?.startEventIdentifier ? [ERRORS.START_EVENT_REQUIRED] : [],
};
return {
fields: {
...emptyFieldState,
...fields,
},
errors: {
...initErrors,
...errors,
},
};
};
import CustomStageFormFields from './create_value_stream_form/custom_stage_fields.vue';
import { validateFields, initializeFormData } from './create_value_stream_form/utils';
import { defaultFields, ERRORS, I18N } from './create_value_stream_form/constants';
import { STAGE_ACTIONS } from '../constants';
import { getAllowedEndEvents, getLabelEventsIdentifiers, isLabelEvent } from '../utils';
export default {
components: {
GlFormGroup,
GlFormInput,
GlFormSelect,
GlLoadingIcon,
LabelsSelector,
GlDropdown,
GlDropdownSectionHeader,
GlDropdownItem,
GlSprintf,
GlButton,
CustomStageFormFields,
},
props: {
events: {
......@@ -93,7 +36,7 @@ export default {
return {
labelEvents: getLabelEventsIdentifiers(this.events),
fields: {},
errors: [],
errors: {},
};
},
computed: {
......@@ -105,21 +48,11 @@ export default {
'formInitialData',
'formErrors',
]),
startEventOptions() {
return [
{ value: null, text: s__('CustomCycleAnalytics|Select start event') },
...this.events.filter(isStartEvent).map(eventToOption),
];
},
endEventOptions() {
const endEvents = getAllowedEndEvents(this.events, this.fields.startEventIdentifier);
return [
{ value: null, text: s__('CustomCycleAnalytics|Select stop event') },
...eventsByIdentifier(this.events, endEvents).map(eventToOption),
];
},
hasStartEvent() {
return this.fields.startEventIdentifier;
hasErrors() {
return (
this.eventMismatchError || Object.values(this.errors).some(errArray => errArray?.length)
);
},
startEventRequiresLabel() {
return isLabelEvent(this.labelEvents, this.fields.startEventIdentifier);
......@@ -127,11 +60,6 @@ export default {
endEventRequiresLabel() {
return isLabelEvent(this.labelEvents, this.fields.endEventIdentifier);
},
hasErrors() {
return (
this.eventMismatchError || Object.values(this.errors).some(errArray => errArray?.length)
);
},
isComplete() {
if (this.hasErrors) {
return false;
......@@ -170,14 +98,10 @@ export default {
return !endEvents.length || !endEvents.includes(endEventIdentifier);
},
saveStageText() {
return this.isEditingCustomStage
? s__('CustomCycleAnalytics|Update stage')
: s__('CustomCycleAnalytics|Add stage');
return this.isEditingCustomStage ? I18N.BTN_UPDATE_STAGE : I18N.BTN_ADD_STAGE;
},
formTitle() {
return this.isEditingCustomStage
? s__('CustomCycleAnalytics|Editing stage')
: s__('CustomCycleAnalytics|New stage');
return this.isEditingCustomStage ? I18N.TITLE_EDIT_STAGE : I18N.TITLE_ADD_STAGE;
},
hasHiddenStages() {
return this.hiddenStages.length;
......@@ -222,34 +146,26 @@ export default {
this.$emit(STAGE_ACTIONS.CREATE, data);
}
},
handleSelectLabel(key, labelId) {
this.fields[key] = labelId;
},
handleClearLabel(key) {
this.fields[key] = null;
},
hasFieldErrors(key) {
return this.errors[key]?.length > 0;
},
fieldErrorMessage(key) {
return this.errors[key]?.join('\n');
},
onUpdateNameField() {
this.errors.name = DEFAULT_STAGE_NAMES.includes(this.fields.name.toLowerCase())
? [ERRORS.STAGE_NAME_EXISTS]
: [];
},
onUpdateStartEventField() {
this.fields.endEventIdentifier = null;
this.errors.endEventIdentifier = [ERRORS.INVALID_EVENT_PAIRS];
},
onUpdateEndEventField() {
this.errors.endEventIdentifier = [];
},
handleRecoverStage(id) {
this.$emit(STAGE_ACTIONS.UPDATE, { id, hidden: false });
},
handleUpdateFields(field, value) {
this.fields = { ...this.fields, [field]: value };
const newErrors = validateFields(this.fields);
newErrors.endEventIdentifier =
this.fields.startEventIdentifier && this.eventMismatchError
? [ERRORS.INVALID_EVENT_PAIRS]
: newErrors.endEventIdentifier;
this.errors = { ...this.errors, ...newErrors };
},
},
I18N,
};
</script>
<template>
......@@ -260,11 +176,13 @@ export default {
<div class="gl-mb-1 gl-display-flex gl-justify-content-space-between gl-align-items-center">
<h4>{{ formTitle }}</h4>
<gl-dropdown
:text="__('Recover hidden stage')"
class="js-recover-hidden-stage-dropdown"
:text="$options.I18N.RECOVER_HIDDEN_STAGE"
data-testid="recover-hidden-stage-dropdown"
right
>
<gl-dropdown-section-header>{{ __('Default stages') }}</gl-dropdown-section-header>
<gl-dropdown-section-header>{{
$options.I18N.RECOVER_STAGE_TITLE
}}</gl-dropdown-section-header>
<template v-if="hasHiddenStages">
<gl-dropdown-item
v-for="stage in hiddenStages"
......@@ -273,111 +191,30 @@ export default {
>{{ stage.title }}</gl-dropdown-item
>
</template>
<p v-else class="mx-3 my-2">{{ __('All default stages are currently visible') }}</p>
<p v-else class="mx-3 my-2">{{ $options.I18N.RECOVER_STAGES_VISIBLE }}</p>
</gl-dropdown>
</div>
<gl-form-group
ref="name"
:label="s__('CustomCycleAnalytics|Name')"
label-for="custom-stage-name"
:state="!hasFieldErrors('name')"
:invalid-feedback="fieldErrorMessage('name')"
>
<gl-form-input
v-model="fields.name"
class="form-control"
type="text"
name="custom-stage-name"
:placeholder="s__('CustomCycleAnalytics|Enter a name for the stage')"
required
@change.native="onUpdateNameField"
/>
</gl-form-group>
<div class="d-flex" :class="{ 'justify-content-between': startEventRequiresLabel }">
<div :class="[startEventRequiresLabel ? 'w-50 mr-1' : 'w-100']">
<gl-form-group
ref="startEventIdentifier"
:label="s__('CustomCycleAnalytics|Start event')"
label-for="custom-stage-start-event"
:state="!hasFieldErrors('startEventIdentifier')"
:invalid-feedback="fieldErrorMessage('startEventIdentifier')"
>
<gl-form-select
v-model="fields.startEventIdentifier"
name="custom-stage-start-event"
:required="true"
:options="startEventOptions"
@change.native="onUpdateStartEventField"
/>
</gl-form-group>
</div>
<div v-if="startEventRequiresLabel" class="w-50 ml-1">
<gl-form-group
ref="startEventLabelId"
:label="s__('CustomCycleAnalytics|Start event label')"
label-for="custom-stage-start-event-label"
:state="!hasFieldErrors('startEventLabelId')"
:invalid-feedback="fieldErrorMessage('startEventLabelId')"
>
<labels-selector
:selected-label-id="[fields.startEventLabelId]"
name="custom-stage-start-event-label"
@selectLabel="handleSelectLabel('startEventLabelId', $event)"
@clearLabel="handleClearLabel('startEventLabelId')"
/>
</gl-form-group>
</div>
</div>
<div class="d-flex" :class="{ 'justify-content-between': endEventRequiresLabel }">
<div :class="[endEventRequiresLabel ? 'w-50 mr-1' : 'w-100']">
<gl-form-group
ref="endEventIdentifier"
:label="s__('CustomCycleAnalytics|Stop event')"
label-for="custom-stage-stop-event"
:state="!hasFieldErrors('endEventIdentifier')"
:invalid-feedback="fieldErrorMessage('endEventIdentifier')"
>
<gl-form-select
v-model="fields.endEventIdentifier"
name="custom-stage-stop-event"
:options="endEventOptions"
:required="true"
:disabled="!hasStartEvent"
@change.native="onUpdateEndEventField"
/>
</gl-form-group>
</div>
<div v-if="endEventRequiresLabel" class="w-50 ml-1">
<gl-form-group
ref="endEventLabelId"
:label="s__('CustomCycleAnalytics|Stop event label')"
label-for="custom-stage-stop-event-label"
:state="!hasFieldErrors('endEventLabelId')"
:invalid-feedback="fieldErrorMessage('endEventLabelId')"
>
<labels-selector
:selected-label-id="[fields.endEventLabelId]"
name="custom-stage-stop-event-label"
@selectLabel="handleSelectLabel('endEventLabelId', $event)"
@clearLabel="handleClearLabel('endEventLabelId')"
<custom-stage-form-fields
:fields="fields"
:label-events="labelEvents"
:errors="errors"
:events="events"
@update="handleUpdateFields"
/>
</gl-form-group>
</div>
</div>
<div class="custom-stage-form-actions">
<gl-button
:disabled="!isDirty"
category="primary"
class="js-save-stage-cancel"
data-testid="cancel-custom-stage"
@click="handleCancel"
>
{{ __('Cancel') }}
{{ $options.I18N.BTN_CANCEL }}
</gl-button>
<gl-button
:disabled="!isComplete || !isDirty"
variant="success"
category="primary"
class="js-save-stage"
data-testid="save-custom-stage"
@click="handleSave"
>
<gl-loading-icon v-if="isSavingCustomStage" size="sm" inline />
......
......@@ -32,7 +32,7 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do
start_event_identifier = :merge_request_created
end_event_identifier = :merge_request_merged
start_label_event = :issue_label_added
stop_label_event = :issue_label_removed
end_label_event = :issue_label_removed
let(:add_stage_button) { '.js-add-stage-button' }
let(:params) { { name: custom_stage_name, start_event_identifier: start_event_identifier, end_event_identifier: end_event_identifier } }
......@@ -57,11 +57,11 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do
end
def select_dropdown_option(name, value = start_event_identifier)
page.find("select[name='#{name}']").all('option').find { |item| item.value == value.to_s }.select_option
page.find("[data-testid='#{name}'] select").all('option').find { |item| item.value == value.to_s }.select_option
end
def select_dropdown_option_by_value(name, value, elem = 'option')
page.find("select[name='#{name}']").find("#{elem}[value=#{value}]").select_option
page.find("[data-testid='#{name}'] select").find("#{elem}[value=#{value}]").select_option
end
def wait_for_labels(field)
......@@ -212,17 +212,16 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do
before do
fill_in 'custom-stage-name', with: custom_stage_name
select_dropdown_option 'custom-stage-start-event', start_event_identifier
select_dropdown_option 'custom-stage-stop-event', end_event_identifier
select_dropdown_option 'custom-stage-end-event', end_event_identifier
end
it 'does not have label dropdowns' do
expect(page).not_to have_content(s_('CustomCycleAnalytics|Start event label'))
expect(page).not_to have_content(s_('CustomCycleAnalytics|Stop event label'))
expect(page).not_to have_content(s_('CustomCycleAnalytics|End event label'))
end
it 'submit button is disabled if a default name is used' do
fill_in 'custom-stage-name', with: 'issue'
click_button(s_('CustomCycleAnalytics|Add stage'))
expect(page).to have_button(s_('CustomCycleAnalytics|Add stage'), disabled: true)
end
......@@ -240,7 +239,7 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do
before do
fill_in 'custom-stage-name', with: custom_stage_with_labels_name
select_dropdown_option_by_value 'custom-stage-start-event', start_label_event
select_dropdown_option_by_value 'custom-stage-stop-event', stop_label_event
select_dropdown_option_by_value 'custom-stage-end-event', end_label_event
end
it 'submit button is disabled' do
......@@ -249,11 +248,11 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do
context 'with labels available' do
start_field = 'custom-stage-start-event-label'
end_field = 'custom-stage-stop-event-label'
end_field = 'custom-stage-end-event-label'
it 'does not contain labels from outside the group' do
wait_for_labels(start_field)
menu = page.find("[name=#{start_field}] .dropdown-menu")
menu = page.find("[data-testid=#{start_field}] .dropdown-menu")
expect(menu).not_to have_content(other_label.name)
expect(menu).to have_content(first_label.name)
......@@ -279,10 +278,10 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do
shared_examples 'can edit custom stages' do
context 'Edit stage form' do
let(:stage_form_class) { '.custom-stage-form' }
let(:stage_save_button) { '.js-save-stage' }
let(:stage_save_button) { '[data-testid="save-custom-stage"]' }
let(:name_field) { 'custom-stage-name' }
let(:start_event_field) { 'custom-stage-start-event' }
let(:end_event_field) { 'custom-stage-stop-event' }
let(:end_event_field) { 'custom-stage-end-event' }
let(:updated_custom_stage_name) { 'Extra uber cool stage' }
before do
......@@ -413,7 +412,7 @@ RSpec.describe 'Customizable Group Value Stream Analytics', :js do
open_recover_stage_dropdown
expect(page.find('.js-recover-hidden-stage-dropdown')).to have_text(s_('CycleAnalyticsStage|Issue'))
expect(page.find("[data-testid='recover-hidden-stage-dropdown']")).to have_text(s_('CycleAnalyticsStage|Issue'))
end
end
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CustomStageForm Editing a custom stage isSavingCustomStage=true displays a loading icon 1`] = `
"<button type=\\"button\\" disabled=\\"disabled\\" class=\\"btn js-save-stage btn-success btn-md disabled gl-button\\">
"<button data-testid=\\"save-custom-stage\\" type=\\"button\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled gl-button\\">
<!---->
<!----> <span class=\\"gl-button-text\\"><span class=\\"gl-spinner-container\\"><span aria-label=\\"Loading\\" class=\\"align-text-bottom gl-spinner gl-spinner-dark gl-spinner-sm\\"></span></span>
Update stage
......@@ -9,7 +9,7 @@ exports[`CustomStageForm Editing a custom stage isSavingCustomStage=true display
`;
exports[`CustomStageForm isSavingCustomStage=true displays a loading icon 1`] = `
"<button type=\\"button\\" disabled=\\"disabled\\" class=\\"btn js-save-stage btn-success btn-md disabled gl-button\\">
"<button data-testid=\\"save-custom-stage\\" type=\\"button\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled gl-button\\">
<!---->
<!----> <span class=\\"gl-button-text\\"><span class=\\"gl-spinner-container\\"><span aria-label=\\"Loading\\" class=\\"align-text-bottom gl-spinner gl-spinner-dark gl-spinner-sm\\"></span></span>
Add stage
......
import { groupLabels, labelStartEvent, labelStopEvent } from '../../mock_data';
export const MERGE_REQUEST_CREATED = 'merge_request_created';
export const ISSUE_CREATED = 'issue_created';
export const MERGE_REQUEST_CLOSED = 'merge_request_closed';
export const ISSUE_CLOSED = 'issue_closed';
export const emptyState = {
id: null,
name: null,
startEventIdentifier: null,
startEventLabelId: null,
endEventIdentifier: null,
endEventLabelId: null,
};
export const emptyErrorsState = {
id: [],
name: [],
startEventIdentifier: [],
startEventLabelId: [],
endEventIdentifier: ['Please select a start event first'],
endEventLabelId: [],
};
export const firstLabel = groupLabels[0];
export const formInitialData = {
id: 74,
name: 'Cool stage pre',
startEventIdentifier: labelStartEvent.identifier,
startEventLabelId: firstLabel.id,
endEventIdentifier: labelStopEvent.identifier,
endEventLabelId: firstLabel.id,
};
export const minimumFields = {
name: 'Cool stage',
startEventIdentifier: MERGE_REQUEST_CREATED,
endEventIdentifier: MERGE_REQUEST_CLOSED,
};
import { initializeFormData } from 'ee/analytics/cycle_analytics/components/create_value_stream_form/utils';
import { emptyErrorsState, emptyState, formInitialData } from './mock_data';
describe('initializeFormData', () => {
const checkInitializedData = (
{ emptyFieldState = emptyState, fields = {}, errors = emptyErrorsState },
{ fields: resultFields = emptyState, errors: resultErrors = emptyErrorsState },
) => {
const res = initializeFormData({ emptyFieldState, fields, errors });
expect(res.fields).toEqual(resultFields);
expect(res.errors).toMatchObject(resultErrors);
};
describe('without a startEventIdentifier', () => {
it('with no errors', () => {
checkInitializedData(
{ fields: {} },
{ errors: { endEventIdentifier: ['Please select a start event first'] } },
);
});
it('with field errors', () => {
const data = { errors: { name: ['is reserved'] } };
const result = {
errors: {
endEventIdentifier: ['Please select a start event first'],
name: ['is reserved'],
},
};
checkInitializedData(data, result);
});
});
describe('with a startEventIdentifier', () => {
it('with no errors', () => {
const data = {
fields: { startEventIdentifier: 'start-event' },
errors: { ...emptyErrorsState, endEventIdentifier: [] },
};
const result = {
fields: { ...emptyState, startEventIdentifier: 'start-event' },
errors: { ...emptyErrorsState, endEventIdentifier: [] },
};
checkInitializedData(data, result);
});
it('with field errors', () => {
const data = {
fields: { startEventIdentifier: 'start-event' },
errors: { name: ['is reserved'] },
};
const result = {
fields: { ...emptyState, startEventIdentifier: 'start-event' },
errors: { endEventIdentifier: [], name: ['is reserved'] },
};
checkInitializedData(data, result);
});
});
describe('with all fields set', () => {
it('with no errors', () => {
const data = { fields: formInitialData };
const result = { fields: formInitialData };
checkInitializedData(data, result);
});
it('with field errors', () => {
const data = { fields: formInitialData, errors: { name: ['is reserved'] } };
const result = { fields: formInitialData, errors: { name: ['is reserved'] } };
checkInitializedData(data, result);
});
});
});
......@@ -2,9 +2,8 @@ import { createLocalVue, mount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import Vuex from 'vuex';
import CustomStageForm, {
initializeFormData,
} from 'ee/analytics/cycle_analytics/components/custom_stage_form.vue';
import CustomStageForm from 'ee/analytics/cycle_analytics/components/custom_stage_form.vue';
import { initializeFormData } from 'ee/analytics/cycle_analytics/components/create_value_stream_form/utils';
import { STAGE_ACTIONS } from 'ee/analytics/cycle_analytics/constants';
import customStagesStore from 'ee/analytics/cycle_analytics/store/modules/custom_stages';
import waitForPromises from 'helpers/wait_for_promises';
......@@ -79,24 +78,32 @@ describe('CustomStageForm', () => {
const findEvent = ev => wrapper.emitted()[ev];
const sel = {
name: '[name="custom-stage-name"]',
startEvent: '[name="custom-stage-start-event"]',
startEventLabel: '[name="custom-stage-start-event-label"]',
endEvent: '[name="custom-stage-stop-event"]',
endEventLabel: '[name="custom-stage-stop-event-label"]',
submit: '.js-save-stage',
cancel: '.js-save-stage-cancel',
name: '[data-testid="custom-stage-name"] input',
startEvent: '[data-testid="custom-stage-start-event"] select',
startEventLabel: '[data-testid="custom-stage-start-event-label"]',
endEvent: '[data-testid="custom-stage-end-event"] select',
endEventLabel: '[data-testid="custom-stage-end-event-label"]',
submit: '[data-testid="save-custom-stage"]',
cancel: '[data-testid="cancel-custom-stage"]',
invalidFeedback: '.invalid-feedback',
recoverStageDropdown: '.js-recover-hidden-stage-dropdown',
recoverStageDropdownTrigger: '.js-recover-hidden-stage-dropdown .dropdown-toggle',
hiddenStageDropdownOption: '.js-recover-hidden-stage-dropdown .dropdown-item',
recoverStageDropdown: '[data-testid="recover-hidden-stage-dropdown"]',
recoverStageDropdownTrigger: '[data-testid="recover-hidden-stage-dropdown"] .dropdown-toggle',
hiddenStageDropdownOption: '[data-testid="recover-hidden-stage-dropdown"] .dropdown-item',
};
function getDropdownOption(_wrapper, dropdown, index) {
function getDropdownOptions(_wrapper, dropdown) {
return _wrapper.find(dropdown).findAll('option');
}
function getDropdownOptionsArray(_wrapper, dropdown) {
return _wrapper
.find(dropdown)
.findAll('option')
.at(index);
.wrappers.map(w => w.attributes('value'));
}
function getDropdownOption(_wrapper, dropdown, index) {
return getDropdownOptions(_wrapper, dropdown).at(index);
}
function selectDropdownOption(_wrapper, dropdown, index) {
......@@ -123,12 +130,12 @@ describe('CustomStageForm', () => {
});
}
const findNameField = _wrapper => _wrapper.find({ ref: 'name' });
const findNameFieldInput = _wrapper => _wrapper.find(sel.name);
const findNameField = _wrapper => _wrapper.find('[data-testid="custom-stage-name"]');
const findStartEventField = _wrapper => _wrapper.find('[data-testid="custom-stage-start-event"]');
function setNameField(_wrapper, value = '') {
findNameFieldInput(_wrapper).setValue(value);
findNameFieldInput(_wrapper).trigger('change');
wrapper.find(sel.name).setValue(value);
wrapper.find(sel.name).trigger('change');
return _wrapper.vm.$nextTick();
}
......@@ -148,7 +155,7 @@ describe('CustomStageForm', () => {
describe.each([
['Name', sel.name, true],
['Start event', sel.startEvent, true],
['Stop event', sel.endEvent, false],
['End event', sel.endEvent, false],
['Submit', sel.submit, false],
['Cancel', sel.cancel, false],
])('Default state', (field, $sel, enabledState) => {
......@@ -275,7 +282,7 @@ describe('CustomStageForm', () => {
});
});
describe('Stop event', () => {
describe('End event', () => {
const startEventArrayIndex = mergeRequestCreatedIndex;
const startEventDropdownIndex = startEventArrayIndex + 1;
const currAllowed = startEvents[startEventArrayIndex].allowedEndEvents;
......@@ -307,7 +314,7 @@ describe('CustomStageForm', () => {
});
});
it('will update the list of stop events when a start event is changed', () => {
it('will update the list of end events when a start event is changed', () => {
let stopOptions = wrapper.find(sel.endEvent).findAll('option');
const selectedStartEvent = startEvents[startEventDropdownIndex];
expect(stopOptions).toHaveLength(1);
......@@ -322,11 +329,11 @@ describe('CustomStageForm', () => {
});
});
it('will display all the valid stop events', () => {
it('will display all the valid end events', () => {
let stopOptions = wrapper.find(sel.endEvent).findAll('option');
const possibleEndEvents = stopEvents.filter(ev => currAllowed.includes(ev.identifier));
expect(stopOptions.at(0).html()).toEqual('<option value="">Select stop event</option>');
expect(stopOptions.at(0).html()).toEqual('<option value="">Select end event</option>');
selectDropdownOption(wrapper, sel.startEvent, startEventDropdownIndex);
......@@ -339,11 +346,11 @@ describe('CustomStageForm', () => {
});
});
it('will not display stop events that are not in the list of allowed stop events', () => {
it('will not display end events that are not in the list of allowed end events', () => {
let stopOptions = wrapper.find(sel.endEvent).findAll('option');
const excludedEndEvents = stopEvents.filter(ev => !currAllowed.includes(ev.identifier));
expect(stopOptions.at(0).html()).toEqual('<option value="">Select stop event</option>');
expect(stopOptions.at(0).html()).toEqual('<option value="">Select end event</option>');
selectDropdownOption(wrapper, sel.startEvent, startEventArrayIndex + 1);
......@@ -358,7 +365,7 @@ describe('CustomStageForm', () => {
});
});
describe('with a stop event selected and a change to the start event', () => {
describe('with a end event selected and a change to the start event', () => {
beforeEach(() => {
wrapper = createComponent();
......@@ -378,27 +385,29 @@ describe('CustomStageForm', () => {
});
it('notifies that a start event needs to be selected first', () => {
wrapper.setData({ fields: { startEventIdentifier: '' } });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.text()).toContain('Please select a start event first');
});
});
it('will notify if the current start and stop event pair is not valid', () => {
it('will notify if the current start and end event pair is not valid', () => {
selectDropdownOption(wrapper, sel.startEvent, 2);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(sel.invalidFeedback).exists()).toEqual(true);
expect(wrapper.find(sel.invalidFeedback).text()).toContain(
'Start event changed, please select a valid stop event',
'Start event changed, please select a valid end event',
);
});
});
it('will update the list of stop events', () => {
const se = wrapper.vm.endEventOptions;
it('will update the list of end events', () => {
const preEndEvents = getDropdownOptionsArray(wrapper, sel.endEvent);
selectDropdownOption(wrapper, sel.startEvent, 2);
return wrapper.vm.$nextTick().then(() => {
expect(se[1].value).not.toEqual(wrapper.vm.endEventOptions[1].value);
const opts = getDropdownOptionsArray(wrapper, sel.endEvent);
expect(preEndEvents).not.toEqual(opts);
});
});
......@@ -410,7 +419,7 @@ describe('CustomStageForm', () => {
});
});
describe('Stop event label', () => {
describe('End event label', () => {
beforeEach(() => {
wrapper = createComponent();
});
......@@ -423,7 +432,7 @@ describe('CustomStageForm', () => {
expect(wrapper.find(sel.startEventLabel).exists()).toEqual(false);
});
it('will display the stop event label field if a label event is selected', () => {
it('will display the end event label field if a label event is selected', () => {
expect(wrapper.find(sel.endEventLabel).exists()).toEqual(false);
wrapper.setData({
......@@ -498,9 +507,12 @@ describe('CustomStageForm', () => {
beforeEach(() => {
wrapper = createComponent();
wrapper.find(sel.name).setValue('Cool stage');
return wrapper.vm
.$nextTick()
.then(() => setEventDropdowns({ startEventDropdownIndex, stopEventDropdownIndex }));
return wrapper.vm.$nextTick().then(() =>
setEventDropdowns({
startEventDropdownIndex,
stopEventDropdownIndex,
}),
);
});
afterEach(() => {
......@@ -666,7 +678,9 @@ describe('CustomStageForm', () => {
return wrapper.vm.$nextTick();
})
.then(() => {
expect(wrapper.vm.fields).toEqual({ ...formInitialData });
expect(wrapper.vm.fields).toEqual({
...formInitialData,
});
});
});
});
......@@ -788,9 +802,9 @@ describe('CustomStageForm', () => {
});
it('renders the errors for the relevant fields', () => {
expect(wrapper.find({ ref: 'name' }).html()).toContain('is reserved');
expect(wrapper.find({ ref: 'name' }).html()).toContain('cant be blank');
expect(wrapper.find({ ref: 'startEventIdentifier' }).html()).toContain('cant be blank');
expect(findNameField(wrapper).html()).toContain('is reserved');
expect(findNameField(wrapper).html()).toContain('cant be blank');
expect(findStartEventField(wrapper).html()).toContain('cant be blank');
});
});
......@@ -827,7 +841,13 @@ describe('CustomStageForm', () => {
wrapper = createComponent({
stubs: formFieldStubs,
initialRootGetters: {
hiddenStages: () => [{ id: 'my-stage', title: 'My default stage', hidden: true }],
hiddenStages: () => [
{
id: 'my-stage',
title: 'My default stage',
hidden: true,
},
],
},
});
});
......@@ -913,7 +933,10 @@ describe('CustomStageForm', () => {
},
errors: {},
});
expect(res.fields).toEqual({ ...emptyFieldState, startEventIdentifier: 'start-event' });
expect(res.fields).toEqual({
...emptyFieldState,
startEventIdentifier: 'start-event',
});
expect(res.errors).toMatchObject({
endEventIdentifier: [],
});
......@@ -929,7 +952,10 @@ describe('CustomStageForm', () => {
name: ['is reserved'],
},
});
expect(res.fields).toEqual({ ...emptyFieldState, startEventIdentifier: 'start-event' });
expect(res.fields).toEqual({
...emptyFieldState,
startEventIdentifier: 'start-event',
});
expect(res.errors).toMatchObject({
endEventIdentifier: [],
name: ['is reserved'],
......
......@@ -2820,9 +2820,6 @@ msgstr ""
msgid "All changes are committed"
msgstr ""
msgid "All default stages are currently visible"
msgstr ""
msgid "All email addresses will be used to identify your commits."
msgstr ""
......@@ -8330,9 +8327,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 ""
......@@ -8345,10 +8354,13 @@ msgstr ""
msgid "CustomCycleAnalytics|Please select a start event first"
msgstr ""
msgid "CustomCycleAnalytics|Select start event"
msgid "CustomCycleAnalytics|Recover hidden stage"
msgstr ""
msgid "CustomCycleAnalytics|Select stop event"
msgid "CustomCycleAnalytics|Select end event"
msgstr ""
msgid "CustomCycleAnalytics|Select start event"
msgstr ""
msgid "CustomCycleAnalytics|Stage name already exists"
......@@ -8357,18 +8369,12 @@ msgstr ""
msgid "CustomCycleAnalytics|Start event"
msgstr ""
msgid "CustomCycleAnalytics|Start event changed, please select a valid stop event"
msgid "CustomCycleAnalytics|Start event changed, please select a valid end event"
msgstr ""
msgid "CustomCycleAnalytics|Start event label"
msgstr ""
msgid "CustomCycleAnalytics|Stop event"
msgstr ""
msgid "CustomCycleAnalytics|Stop event label"
msgstr ""
msgid "CustomCycleAnalytics|Update stage"
msgstr ""
......@@ -8959,9 +8965,6 @@ msgstr ""
msgid "Default projects limit"
msgstr ""
msgid "Default stages"
msgstr ""
msgid "Default: Map a FogBugz account ID to a full name"
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