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;
};
......@@ -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);
});
});
});
......@@ -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