Commit e9a0acb2 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '325990-vsa-hidden-stages-position' into 'master'

VSA restore hidden stages

See merge request gitlab-org/gitlab!62575
parents 77d8e925 d4e5bbf7
import { isEqual, pick } from 'lodash';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import { DEFAULT_STAGE_NAMES } from '../../constants';
import { isStartEvent, getAllowedEndEvents, eventToOption, eventsByIdentifier } from '../../utils';
import {
i18n,
......@@ -89,16 +88,17 @@ export const initializeFormData = ({ fields, errors }) => {
* the name of the field.g
*
* @param {Object} fields key value pair of form field values
* @param {Object} defaultStageNames array of lower case default value stream names
* @returns {Object} key value pair of form fields with an array of errors
*/
export const validateStage = (fields) => {
export const validateStage = (fields, defaultStageNames = []) => {
const newErrors = {};
if (fields?.name) {
if (fields.name.length > NAME_MAX_LENGTH) {
newErrors.name = [ERRORS.MAX_LENGTH];
}
if (fields?.custom && DEFAULT_STAGE_NAMES.includes(fields.name.toLowerCase())) {
if (fields?.custom && defaultStageNames.includes(fields.name.toLowerCase())) {
newErrors.name = [ERRORS.STAGE_NAME_EXISTS];
}
} else {
......@@ -216,16 +216,29 @@ const prepareDefaultStage = (defaultStageConfig, { name, ...rest }) => {
};
};
const generateHiddenDefaultStages = (defaultStageConfig, stageNames) => {
// We use the stage name to check for any default stages that might be hidden
// Currently the default stages can't be renamed
return defaultStageConfig
.filter(({ name }) => !stageNames.includes(name.toLowerCase()))
.map((data) => ({ ...data, hidden: true }));
};
/**
* Returns a valid array of value stream stages for
* use in the value stream form
*
* @param {Array} stage an array of raw value stream stages retrieved from the vuex store
* @param {Array} stage an array of raw value stream stages retrieved from the vuex store
* @param {Array} defaultStageConfig an array of the default value stream stages retrieved from the backend
* @param {Array} selectedValueStreamStages an array of raw value stream stages retrieved from the vuex store
* @returns {Object} the same stage with fields adjusted for the value stream form
*/
export const generateInitialStageData = (defaultStageConfig, selectedValueStreamStages) =>
selectedValueStreamStages.map(
export const generateInitialStageData = (defaultStageConfig, selectedValueStreamStages) => {
const hiddenDefaultStages = generateHiddenDefaultStages(
defaultStageConfig,
selectedValueStreamStages.map((s) => s.name.toLowerCase()),
);
const combinedStages = [...selectedValueStreamStages, ...hiddenDefaultStages];
return combinedStages.map(
({ startEventIdentifier = null, endEventIdentifier = null, custom = false, ...rest }) => {
const stageData =
custom && startEventIdentifier && endEventIdentifier
......@@ -241,3 +254,4 @@ export const generateInitialStageData = (defaultStageConfig, selectedValueStream
return {};
},
);
};
......@@ -3,6 +3,7 @@ import { GlButton, GlForm, GlFormInput, GlFormGroup, GlFormRadioGroup, GlModal }
import { cloneDeep } from 'lodash';
import Vue from 'vue';
import { mapState, mapActions } from 'vuex';
import { filterStagesByHiddenStatus } from '~/cycle_analytics/utils';
import { swapArrayItems } from '~/lib/utils/array_utility';
import { sprintf } from '~/locale';
import Tracking from '~/tracking';
......@@ -72,20 +73,20 @@ export default {
data() {
const {
defaultStageConfig = [],
initialData: { name: initialName, stages: initialStages },
initialData: { name: initialName, stages: initialStages = [] },
initialFormErrors,
initialPreset,
} = this;
const { name: nameError = [], stages: stageErrors = [{}] } = initialFormErrors;
const additionalFields = {
stages: this.isEditing
? cloneDeep(initialStages)
? filterStagesByHiddenStatus(cloneDeep(initialStages), false)
: initializeStages(defaultStageConfig, initialPreset),
stageErrors:
cloneDeep(stageErrors) || initializeStageErrors(defaultStageConfig, initialPreset),
};
return {
hiddenStages: [],
hiddenStages: filterStagesByHiddenStatus(initialStages),
selectedPreset: initialPreset,
presetOptions: PRESET_OPTIONS,
name: initialName,
......@@ -139,6 +140,9 @@ export default {
canRestore() {
return this.hiddenStages.length || this.isDirtyEditing;
},
defaultValueStreamNames() {
return this.defaultStageConfig.map(({ name }) => name);
},
},
methods: {
...mapActions(['createValueStream', 'updateValueStream']),
......@@ -192,7 +196,7 @@ export default {
return current.trim().toLowerCase() !== original.trim().toLowerCase();
},
validateStages() {
return this.stages.map(validateStage);
return this.stages.map((stage) => validateStage(stage, this.defaultValueStreamNames));
},
validate() {
const { name } = this;
......@@ -290,6 +294,9 @@ export default {
initializeStageErrors(this.defaultStageConfig, this.selectedPreset),
);
},
restoreActionTestId(index) {
return `stage-action-restore-${index}`;
},
},
i18n,
};
......@@ -381,13 +388,20 @@ export default {
</div>
<div v-if="hiddenStages.length">
<hr />
<gl-form-group v-for="(stage, hiddenStageIndex) in hiddenStages" :key="stage.id">
<gl-form-group
v-for="(stage, hiddenStageIndex) in hiddenStages"
:key="stage.id"
data-testid="vsa-hidden-stage"
>
<span class="gl-m-0 gl-vertical-align-middle gl-mr-3 gl-font-weight-bold">{{
recoverStageTitle(stage.name)
}}</span>
<gl-button variant="link" @click="onRestore(hiddenStageIndex)">{{
$options.i18n.RESTORE_HIDDEN_STAGE
}}</gl-button>
<gl-button
variant="link"
:data-testid="restoreActionTestId(hiddenStageIndex)"
@click="onRestore(hiddenStageIndex)"
>{{ $options.i18n.RESTORE_HIDDEN_STAGE }}</gl-button
>
</gl-form-group>
</div>
</div>
......
......@@ -112,9 +112,13 @@ export default {
onEdit() {
this.showCreateModal = true;
this.isEditing = true;
const stages = generateInitialStageData(
this.defaultStageConfig,
this.selectedValueStreamStages,
);
this.initialData = {
...this.selectedValueStream,
stages: generateInitialStageData(this.defaultStageConfig, this.selectedValueStreamStages),
stages,
};
},
slugify(valueStreamTitle) {
......
......@@ -7,29 +7,6 @@ export const DEFAULT_DAYS_IN_PAST = 30;
export const EVENTS_LIST_ITEM_LIMIT = 50;
export const EMPTY_STAGE_TEXT = {
issue: __(
'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.',
),
plan: __(
'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.',
),
code: __(
'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.',
),
test: __(
'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.',
),
review: __(
'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.',
),
staging: __(
'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.',
),
};
export const DEFAULT_STAGE_NAMES = [...Object.keys(EMPTY_STAGE_TEXT)];
export const TASKS_BY_TYPE_SUBJECT_ISSUE = 'Issue';
export const TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST = 'MergeRequest';
export const TASKS_BY_TYPE_MAX_LABELS = 15;
......
import dateFormat from 'dateformat';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { dateFormats } from './constants';
export const toYmd = (date) => dateFormat(date, dateFormats.isoDate);
......@@ -127,7 +128,10 @@ export const buildCycleAnalyticsInitialData = ({
labelsPath,
milestonesPath,
defaultStageConfig: defaultStages
? buildDefaultStagesFromJSON(defaultStages).map(convertObjectPropsToCamelCase)
? buildDefaultStagesFromJSON(defaultStages).map(({ name, ...rest }) => ({
...convertObjectPropsToCamelCase(rest),
name: capitalizeFirstCharacter(name),
}))
: [],
stage: JSON.parse(stage),
});
......
......@@ -134,18 +134,27 @@ RSpec.describe 'Multiple value streams', :js do
expect(path_nav_elem).not_to have_text("Cool custom stage - name")
end
it 'can hide default stages' do
it 'can hide and restore default stages' do
click_action_button('hide', 5)
click_action_button('hide', 4)
click_action_button('hide', 3)
page.find_button(_('Save Value Stream')).click
click_button(_('Save Value Stream'))
wait_for_requests
expect(page).to have_text(_("'%{name}' Value Stream saved") % { name: custom_value_stream_name })
expect(path_nav_elem).not_to have_text("Staging")
expect(path_nav_elem).not_to have_text("Review")
expect(path_nav_elem).not_to have_text("Test")
click_button(_('Edit'))
click_action_button('restore', 0)
click_button(_('Save Value Stream'))
wait_for_requests
expect(page).to have_text(_("'%{name}' Value Stream saved") % { name: custom_value_stream_name })
expect(path_nav_elem).to have_text("Test")
end
end
end
......
......@@ -10,9 +10,11 @@ import {
formatStageDataForSubmission,
generateInitialStageData,
} from 'ee/analytics/cycle_analytics/components/create_value_stream_form/utils';
import { defaultStageConfig } from '../../mock_data';
import { emptyErrorsState, emptyState, formInitialData } from './mock_data';
const defaultStageNames = defaultStageConfig.map(({ name }) => name);
describe('initializeFormData', () => {
const checkInitializedData = (
{ emptyFieldState = emptyState, fields = {}, errors = emptyErrorsState },
......@@ -102,7 +104,7 @@ describe('validateStage', () => {
${'name'} | ${'Issue'} | ${ERRORS.STAGE_NAME_EXISTS} | ${'is a capitalized default name'}
${'endEventIdentifier'} | ${''} | ${ERRORS.START_EVENT_REQUIRED} | ${'has no corresponding start event'}
`('returns "$error" if $field $msg', ({ field, value, error }) => {
const result = validateStage({ ...defaultFields, [field]: value });
const result = validateStage({ ...defaultFields, [field]: value }, defaultStageNames);
expectFieldError({ result, error, field });
});
......@@ -288,6 +290,19 @@ describe('generateInitialStageData', () => {
expect(res).toEqual({});
});
it('will set missing default stages to `hidden`', () => {
const hiddenStage = {
id: 'fake-hidden',
name: 'fake-hidden',
custom: false,
startEventIdentifier: 'merge_request_created',
endEventIdentifier: 'merge_request_closed',
};
const res = generateInitialStageData([initialCustomStage, hiddenStage], [initialCustomStage]);
expect(res[1]).toEqual({ ...hiddenStage, hidden: true, isDefault: true });
});
describe('custom stages', () => {
it.each`
key | value
......
......@@ -84,11 +84,15 @@ describe('ValueStreamForm', () => {
);
const findModal = () => wrapper.findComponent(GlModal);
const clickSubmit = () => findModal().vm.$emit('primary', mockEvent);
const clickAddStage = () => findModal().vm.$emit('secondary', mockEvent);
const findExtendedFormFields = () => wrapper.findByTestId('extended-form-fields');
const findPresetSelector = () => wrapper.findByTestId('vsa-preset-selector');
const findRestoreButton = (index) => wrapper.findByTestId(`stage-action-restore-${index}`);
const findHiddenStages = () => wrapper.findAllByTestId('vsa-hidden-stage').wrappers;
const findBtn = (btn) => findModal().props(btn);
const clickSubmit = () => findModal().vm.$emit('primary', mockEvent);
const clickAddStage = () => findModal().vm.$emit('secondary', mockEvent);
const clickRestoreStageAtIndex = (index) => findRestoreButton(index).vm.$emit('click');
const expectFieldError = (testId, error = '') =>
expect(wrapper.findByTestId(testId).attributes('invalid-feedback')).toBe(error);
......@@ -116,6 +120,10 @@ describe('ValueStreamForm', () => {
});
});
it('does not display any hidden stages', () => {
expect(findHiddenStages().length).toBe(0);
});
describe('Add stage button', () => {
beforeEach(() => {
wrapper = createComponent({
......@@ -203,6 +211,39 @@ describe('ValueStreamForm', () => {
expect(findBtn('actionPrimary').text).toBe('Save Value Stream');
});
it('does not display any hidden stages', () => {
expect(findHiddenStages().length).toBe(0);
});
describe('with hidden stages', () => {
const hiddenStages = defaultStageConfig.map((s) => ({ ...s, hidden: true }));
beforeEach(() => {
wrapper = createComponent({
props: {
initialPreset,
initialData: { ...initialData, stages: [...initialData.stages, ...hiddenStages] },
isEditing: true,
},
});
});
it('displays hidden each stage', () => {
expect(findHiddenStages().length).toBe(hiddenStages.length);
findHiddenStages().forEach((s) => {
expect(s.text()).toContain('Restore stage');
});
});
it('when `Restore stage` is clicked, the stage is restored', async () => {
await clickRestoreStageAtIndex(1);
expect(findHiddenStages().length).toBe(hiddenStages.length - 1);
expect(wrapper.vm.stages.length).toBe(stageCount + 1);
});
});
describe('Add stage button', () => {
beforeEach(() => {
wrapper = createComponent({
......
......@@ -32383,9 +32383,6 @@ msgstr ""
msgid "The character highlighter helps you keep the subject line to %{titleLength} characters and wrap the body at %{bodyLength} so they are readable in git."
msgstr ""
msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request."
msgstr ""
msgid "The collection of events added to the data gathered for that stage."
msgstr ""
......@@ -32547,9 +32544,6 @@ msgstr ""
msgid "The invitation was successfully resent."
msgstr ""
msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage."
msgstr ""
msgid "The issue was successfully promoted to an epic. Redirecting to epic..."
msgstr ""
......@@ -32649,9 +32643,6 @@ msgstr ""
msgid "The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user."
msgstr ""
msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit."
msgstr ""
msgid "The private key to use when a client certificate is provided. This value is encrypted at rest."
msgstr ""
......@@ -32715,9 +32706,6 @@ msgstr ""
msgid "The repository must be accessible over %{code_open}http://%{code_close}, %{code_open}https://%{code_close}, %{code_open}ssh://%{code_close} or %{code_open}git://%{code_close}."
msgstr ""
msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request."
msgstr ""
msgid "The same shared runner executes code from multiple projects, unless you configure autoscaling with %{link} set to 1 (which it is on GitLab.com)."
msgstr ""
......@@ -32742,9 +32730,6 @@ msgstr ""
msgid "The specified tab is invalid, please select another"
msgstr ""
msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time."
msgstr ""
msgid "The start date must be ealier than the end date."
msgstr ""
......@@ -32757,9 +32742,6 @@ msgstr ""
msgid "The tag name can't be changed for an existing release."
msgstr ""
msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running."
msgstr ""
msgid "The time taken by each data entry gathered by that stage."
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