Commit e236bb09 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Move the custom value stream fields to a new component

Extracts the custom stage form fields into
a separate component and moves some of the related
logic into utils and constants
parent afb907e4
......@@ -11,7 +11,7 @@ import TypeOfWorkCharts from './type_of_work_charts.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import { toYmd } from '../../shared/utils';
import StageTableNav from './stage_table_nav.vue';
import CustomStageForm from './custom_stage_form.vue';
import CustomStageForm from './create_value_stream_form/custom_stage_form.vue';
import PathNavigation from './path_navigation.vue';
import FilterBar from './filter_bar.vue';
import ValueStreamSelect from './value_stream_select.vue';
......
import { s__ } from '~/locale';
export const I18N = {
SELECT_START_EVENT: s__('CustomCycleAnalytics|Select start event'),
SELECT_END_EVENT: s__('CustomCycleAnalytics|Select stop event'),
};
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 { s__ } from '~/locale';
import LabelsSelector from '../labels_selector.vue';
import {
isStartEvent,
isLabelEvent,
getAllowedEndEvents,
eventToOption,
eventsByIdentifier,
} 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: {
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 end event') },
...eventsByIdentifier(this.events, endEvents).map(eventToOption),
];
},
hasStartEvent() {
return this.fields.startEventIdentifier;
},
startEventRequiresLabel() {
return isLabelEvent(this.labelEvents, this.fields.startEventIdentifier);
},
endEventRequiresLabel() {
return isLabelEvent(this.labelEvents, this.fields.endEventIdentifier);
},
},
methods: {
hasFieldErrors(key) {
return this.errors[key]?.length > 0;
},
fieldErrorMessage(key) {
return this.errors[key]?.join('\n');
},
handleUpdateField(field, value) {
this.$emit('update', field, value);
},
},
};
</script>
<template>
<div>
<gl-form-group
:label="s__('CustomCycleAnalytics|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="s__('CustomCycleAnalytics|Enter a name for the stage')"
required
@input="handleUpdateField('name', $event)"
/>
</gl-form-group>
<div class="d-flex" :class="{ 'justify-content-between': startEventRequiresLabel }">
<div :class="[startEventRequiresLabel ? 'w-50 mr-1' : 'w-100']">
<gl-form-group
data-testid="custom-stage-start-event-identifier"
: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"
@input="handleUpdateField('startEventIdentifier', $event)"
/>
</gl-form-group>
</div>
<div v-if="startEventRequiresLabel" class="w-50 ml-1">
<gl-form-group
data-testid="custom-stage-start-event-label-id"
: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="handleUpdateField('startEventLabelId', $event)"
/>
</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
data-testid="custom-stage-end-event-identifier"
:label="s__('CustomCycleAnalytics|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="endEventOptions"
: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-id"
:label="s__('CustomCycleAnalytics|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';
export const startEventOptions = eventsList => [
{ value: null, text: I18N.SELECT_START_EVENT },
...eventsList.filter(isStartEvent).map(eventToOption),
];
export const endEventOptions = (eventsList, startEventIdentifier) => {
const endEvents = getAllowedEndEvents(eventsList, startEventIdentifier);
return [
{ value: null, text: I18N.SELECT_END_EVENT },
...eventsByIdentifier(eventsList, endEvents).map(eventToOption),
];
};
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,
},
};
};
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?.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,
......@@ -14,74 +11,21 @@ import {
} 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 } 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 +37,7 @@ export default {
return {
labelEvents: getLabelEventsIdentifiers(this.events),
fields: {},
errors: [],
errors: {},
};
},
computed: {
......@@ -105,21 +49,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 +61,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;
......@@ -222,33 +151,23 @@ 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.eventMismatchError
? [ERRORS.INVALID_EVENT_PAIRS]
: newErrors.endEventIdentifier;
this.errors = newErrors;
},
},
};
</script>
......@@ -261,7 +180,7 @@ export default {
<h4>{{ formTitle }}</h4>
<gl-dropdown
:text="__('Recover hidden stage')"
class="js-recover-hidden-stage-dropdown"
data-testid="recover-hidden-stage-dropdown"
right
>
<gl-dropdown-section-header>{{ __('Default stages') }}</gl-dropdown-section-header>
......@@ -276,99 +195,18 @@ export default {
<p v-else class="mx-3 my-2">{{ __('All default stages are currently 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')"
/>
</gl-form-group>
</div>
</div>
<custom-stage-form-fields
:fields="fields"
:label-events="labelEvents"
:errors="errors"
:events="events"
@update="handleUpdateFields"
/>
<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') }}
......@@ -377,7 +215,7 @@ export default {
: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 />
......
......@@ -279,7 +279,7 @@ 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' }
......@@ -413,7 +413,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
......
......@@ -5,7 +5,7 @@ import MockAdapter from 'axios-mock-adapter';
import Vuex from 'vuex';
import AddStageButton from 'ee/analytics/cycle_analytics/components/add_stage_button.vue';
import Component from 'ee/analytics/cycle_analytics/components/base.vue';
import CustomStageForm from 'ee/analytics/cycle_analytics/components/custom_stage_form.vue';
import CustomStageForm from 'ee/analytics/cycle_analytics/components/create_value_stream_form/custom_stage_form.vue';
import DurationChart from 'ee/analytics/cycle_analytics/components/duration_chart.vue';
import FilterBar from 'ee/analytics/cycle_analytics/components/filter_bar.vue';
import Metrics from 'ee/analytics/cycle_analytics/components/metrics.vue';
......
export const findName = wrapper => wrapper.find('[data-testid="custom-stage-name"]');
export const findStartEvent = wrapper =>
wrapper.find('[data-testid="custom-stage-start-event-identifier"]');
export const findEndEvent = wrapper =>
wrapper.find('[data-testid="custom-stage-end-event-identifier"]');
export const findStartEventLabel = wrapper =>
wrapper.find('[data-testid="custom-stage-start-event-label-id"]');
export const findEndEventLabel = wrapper =>
wrapper.find('[data-testid="custom-stage-end-event-label-id"]');
export const formatStartEventOpts = events => [
{ text: 'Select start event', value: null },
...events
.filter(ev => ev.canBeStartEvent)
.map(({ name: text, identifier: value }) => ({ text, value })),
];
export const formatEndEventOpts = events => [
{ text: 'Select end event', value: null },
...events
.filter(ev => !ev.canBeStartEvent)
.map(({ name: text, identifier: value }) => ({ text, value })),
];
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);
});
});
});
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