Commit d4e5bbf7 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo Committed by Martin Wortschack

VSA restore hidden stages

parent 43694751
import { isEqual, pick } from 'lodash'; import { isEqual, pick } from 'lodash';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import { DEFAULT_STAGE_NAMES } from '../../constants';
import { isStartEvent, getAllowedEndEvents, eventToOption, eventsByIdentifier } from '../../utils'; import { isStartEvent, getAllowedEndEvents, eventToOption, eventsByIdentifier } from '../../utils';
import { import {
i18n, i18n,
...@@ -89,16 +88,17 @@ export const initializeFormData = ({ fields, errors }) => { ...@@ -89,16 +88,17 @@ export const initializeFormData = ({ fields, errors }) => {
* the name of the field.g * the name of the field.g
* *
* @param {Object} fields key value pair of form field values * @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 * @returns {Object} key value pair of form fields with an array of errors
*/ */
export const validateStage = (fields) => { export const validateStage = (fields, defaultStageNames = []) => {
const newErrors = {}; const newErrors = {};
if (fields?.name) { if (fields?.name) {
if (fields.name.length > NAME_MAX_LENGTH) { if (fields.name.length > NAME_MAX_LENGTH) {
newErrors.name = [ERRORS.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]; newErrors.name = [ERRORS.STAGE_NAME_EXISTS];
} }
} else { } else {
...@@ -216,16 +216,29 @@ const prepareDefaultStage = (defaultStageConfig, { name, ...rest }) => { ...@@ -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 * Returns a valid array of value stream stages for
* use in the value stream form * use in the value stream form
* *
* @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} stage an array of raw value stream stages retrieved from the vuex store * @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 * @returns {Object} the same stage with fields adjusted for the value stream form
*/ */
export const generateInitialStageData = (defaultStageConfig, selectedValueStreamStages) => export const generateInitialStageData = (defaultStageConfig, selectedValueStreamStages) => {
selectedValueStreamStages.map( const hiddenDefaultStages = generateHiddenDefaultStages(
defaultStageConfig,
selectedValueStreamStages.map((s) => s.name.toLowerCase()),
);
const combinedStages = [...selectedValueStreamStages, ...hiddenDefaultStages];
return combinedStages.map(
({ startEventIdentifier = null, endEventIdentifier = null, custom = false, ...rest }) => { ({ startEventIdentifier = null, endEventIdentifier = null, custom = false, ...rest }) => {
const stageData = const stageData =
custom && startEventIdentifier && endEventIdentifier custom && startEventIdentifier && endEventIdentifier
...@@ -241,3 +254,4 @@ export const generateInitialStageData = (defaultStageConfig, selectedValueStream ...@@ -241,3 +254,4 @@ export const generateInitialStageData = (defaultStageConfig, selectedValueStream
return {}; return {};
}, },
); );
};
...@@ -3,6 +3,7 @@ import { GlButton, GlForm, GlFormInput, GlFormGroup, GlFormRadioGroup, GlModal } ...@@ -3,6 +3,7 @@ import { GlButton, GlForm, GlFormInput, GlFormGroup, GlFormRadioGroup, GlModal }
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import Vue from 'vue'; import Vue from 'vue';
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { filterStagesByHiddenStatus } from '~/cycle_analytics/utils';
import { swapArrayItems } from '~/lib/utils/array_utility'; import { swapArrayItems } from '~/lib/utils/array_utility';
import { sprintf } from '~/locale'; import { sprintf } from '~/locale';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
...@@ -72,20 +73,20 @@ export default { ...@@ -72,20 +73,20 @@ export default {
data() { data() {
const { const {
defaultStageConfig = [], defaultStageConfig = [],
initialData: { name: initialName, stages: initialStages }, initialData: { name: initialName, stages: initialStages = [] },
initialFormErrors, initialFormErrors,
initialPreset, initialPreset,
} = this; } = this;
const { name: nameError = [], stages: stageErrors = [{}] } = initialFormErrors; const { name: nameError = [], stages: stageErrors = [{}] } = initialFormErrors;
const additionalFields = { const additionalFields = {
stages: this.isEditing stages: this.isEditing
? cloneDeep(initialStages) ? filterStagesByHiddenStatus(cloneDeep(initialStages), false)
: initializeStages(defaultStageConfig, initialPreset), : initializeStages(defaultStageConfig, initialPreset),
stageErrors: stageErrors:
cloneDeep(stageErrors) || initializeStageErrors(defaultStageConfig, initialPreset), cloneDeep(stageErrors) || initializeStageErrors(defaultStageConfig, initialPreset),
}; };
return { return {
hiddenStages: [], hiddenStages: filterStagesByHiddenStatus(initialStages),
selectedPreset: initialPreset, selectedPreset: initialPreset,
presetOptions: PRESET_OPTIONS, presetOptions: PRESET_OPTIONS,
name: initialName, name: initialName,
...@@ -139,6 +140,9 @@ export default { ...@@ -139,6 +140,9 @@ export default {
canRestore() { canRestore() {
return this.hiddenStages.length || this.isDirtyEditing; return this.hiddenStages.length || this.isDirtyEditing;
}, },
defaultValueStreamNames() {
return this.defaultStageConfig.map(({ name }) => name);
},
}, },
methods: { methods: {
...mapActions(['createValueStream', 'updateValueStream']), ...mapActions(['createValueStream', 'updateValueStream']),
...@@ -192,7 +196,7 @@ export default { ...@@ -192,7 +196,7 @@ export default {
return current.trim().toLowerCase() !== original.trim().toLowerCase(); return current.trim().toLowerCase() !== original.trim().toLowerCase();
}, },
validateStages() { validateStages() {
return this.stages.map(validateStage); return this.stages.map((stage) => validateStage(stage, this.defaultValueStreamNames));
}, },
validate() { validate() {
const { name } = this; const { name } = this;
...@@ -290,6 +294,9 @@ export default { ...@@ -290,6 +294,9 @@ export default {
initializeStageErrors(this.defaultStageConfig, this.selectedPreset), initializeStageErrors(this.defaultStageConfig, this.selectedPreset),
); );
}, },
restoreActionTestId(index) {
return `stage-action-restore-${index}`;
},
}, },
i18n, i18n,
}; };
...@@ -381,13 +388,20 @@ export default { ...@@ -381,13 +388,20 @@ export default {
</div> </div>
<div v-if="hiddenStages.length"> <div v-if="hiddenStages.length">
<hr /> <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">{{ <span class="gl-m-0 gl-vertical-align-middle gl-mr-3 gl-font-weight-bold">{{
recoverStageTitle(stage.name) recoverStageTitle(stage.name)
}}</span> }}</span>
<gl-button variant="link" @click="onRestore(hiddenStageIndex)">{{ <gl-button
$options.i18n.RESTORE_HIDDEN_STAGE variant="link"
}}</gl-button> :data-testid="restoreActionTestId(hiddenStageIndex)"
@click="onRestore(hiddenStageIndex)"
>{{ $options.i18n.RESTORE_HIDDEN_STAGE }}</gl-button
>
</gl-form-group> </gl-form-group>
</div> </div>
</div> </div>
......
...@@ -112,9 +112,13 @@ export default { ...@@ -112,9 +112,13 @@ export default {
onEdit() { onEdit() {
this.showCreateModal = true; this.showCreateModal = true;
this.isEditing = true; this.isEditing = true;
const stages = generateInitialStageData(
this.defaultStageConfig,
this.selectedValueStreamStages,
);
this.initialData = { this.initialData = {
...this.selectedValueStream, ...this.selectedValueStream,
stages: generateInitialStageData(this.defaultStageConfig, this.selectedValueStreamStages), stages,
}; };
}, },
slugify(valueStreamTitle) { slugify(valueStreamTitle) {
......
...@@ -7,29 +7,6 @@ export const DEFAULT_DAYS_IN_PAST = 30; ...@@ -7,29 +7,6 @@ export const DEFAULT_DAYS_IN_PAST = 30;
export const EVENTS_LIST_ITEM_LIMIT = 50; 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_ISSUE = 'Issue';
export const TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST = 'MergeRequest'; export const TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST = 'MergeRequest';
export const TASKS_BY_TYPE_MAX_LABELS = 15; export const TASKS_BY_TYPE_MAX_LABELS = 15;
......
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { dateFormats } from './constants'; import { dateFormats } from './constants';
export const toYmd = (date) => dateFormat(date, dateFormats.isoDate); export const toYmd = (date) => dateFormat(date, dateFormats.isoDate);
...@@ -127,7 +128,10 @@ export const buildCycleAnalyticsInitialData = ({ ...@@ -127,7 +128,10 @@ export const buildCycleAnalyticsInitialData = ({
labelsPath, labelsPath,
milestonesPath, milestonesPath,
defaultStageConfig: defaultStages defaultStageConfig: defaultStages
? buildDefaultStagesFromJSON(defaultStages).map(convertObjectPropsToCamelCase) ? buildDefaultStagesFromJSON(defaultStages).map(({ name, ...rest }) => ({
...convertObjectPropsToCamelCase(rest),
name: capitalizeFirstCharacter(name),
}))
: [], : [],
stage: JSON.parse(stage), stage: JSON.parse(stage),
}); });
......
...@@ -134,18 +134,27 @@ RSpec.describe 'Multiple value streams', :js do ...@@ -134,18 +134,27 @@ RSpec.describe 'Multiple value streams', :js do
expect(path_nav_elem).not_to have_text("Cool custom stage - name") expect(path_nav_elem).not_to have_text("Cool custom stage - name")
end end
it 'can hide default stages' do it 'can hide and restore default stages' do
click_action_button('hide', 5) click_action_button('hide', 5)
click_action_button('hide', 4) click_action_button('hide', 4)
click_action_button('hide', 3) click_action_button('hide', 3)
page.find_button(_('Save Value Stream')).click click_button(_('Save Value Stream'))
wait_for_requests wait_for_requests
expect(page).to have_text(_("'%{name}' Value Stream saved") % { name: custom_value_stream_name }) 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("Staging")
expect(path_nav_elem).not_to have_text("Review") expect(path_nav_elem).not_to have_text("Review")
expect(path_nav_elem).not_to have_text("Test") 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 end
end end
......
...@@ -10,9 +10,11 @@ import { ...@@ -10,9 +10,11 @@ import {
formatStageDataForSubmission, formatStageDataForSubmission,
generateInitialStageData, generateInitialStageData,
} from 'ee/analytics/cycle_analytics/components/create_value_stream_form/utils'; } from 'ee/analytics/cycle_analytics/components/create_value_stream_form/utils';
import { defaultStageConfig } from '../../mock_data';
import { emptyErrorsState, emptyState, formInitialData } from './mock_data'; import { emptyErrorsState, emptyState, formInitialData } from './mock_data';
const defaultStageNames = defaultStageConfig.map(({ name }) => name);
describe('initializeFormData', () => { describe('initializeFormData', () => {
const checkInitializedData = ( const checkInitializedData = (
{ emptyFieldState = emptyState, fields = {}, errors = emptyErrorsState }, { emptyFieldState = emptyState, fields = {}, errors = emptyErrorsState },
...@@ -102,7 +104,7 @@ describe('validateStage', () => { ...@@ -102,7 +104,7 @@ describe('validateStage', () => {
${'name'} | ${'Issue'} | ${ERRORS.STAGE_NAME_EXISTS} | ${'is a capitalized default name'} ${'name'} | ${'Issue'} | ${ERRORS.STAGE_NAME_EXISTS} | ${'is a capitalized default name'}
${'endEventIdentifier'} | ${''} | ${ERRORS.START_EVENT_REQUIRED} | ${'has no corresponding start event'} ${'endEventIdentifier'} | ${''} | ${ERRORS.START_EVENT_REQUIRED} | ${'has no corresponding start event'}
`('returns "$error" if $field $msg', ({ field, value, error }) => { `('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 }); expectFieldError({ result, error, field });
}); });
...@@ -288,6 +290,19 @@ describe('generateInitialStageData', () => { ...@@ -288,6 +290,19 @@ describe('generateInitialStageData', () => {
expect(res).toEqual({}); 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', () => { describe('custom stages', () => {
it.each` it.each`
key | value key | value
......
...@@ -84,11 +84,15 @@ describe('ValueStreamForm', () => { ...@@ -84,11 +84,15 @@ describe('ValueStreamForm', () => {
); );
const findModal = () => wrapper.findComponent(GlModal); 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 findExtendedFormFields = () => wrapper.findByTestId('extended-form-fields');
const findPresetSelector = () => wrapper.findByTestId('vsa-preset-selector'); 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 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 = '') => const expectFieldError = (testId, error = '') =>
expect(wrapper.findByTestId(testId).attributes('invalid-feedback')).toBe(error); expect(wrapper.findByTestId(testId).attributes('invalid-feedback')).toBe(error);
...@@ -116,6 +120,10 @@ describe('ValueStreamForm', () => { ...@@ -116,6 +120,10 @@ describe('ValueStreamForm', () => {
}); });
}); });
it('does not display any hidden stages', () => {
expect(findHiddenStages().length).toBe(0);
});
describe('Add stage button', () => { describe('Add stage button', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ wrapper = createComponent({
...@@ -203,6 +211,39 @@ describe('ValueStreamForm', () => { ...@@ -203,6 +211,39 @@ describe('ValueStreamForm', () => {
expect(findBtn('actionPrimary').text).toBe('Save Value Stream'); 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', () => { describe('Add stage button', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ wrapper = createComponent({
......
...@@ -32377,9 +32377,6 @@ msgstr "" ...@@ -32377,9 +32377,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." 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 "" 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." msgid "The collection of events added to the data gathered for that stage."
msgstr "" msgstr ""
...@@ -32541,9 +32538,6 @@ msgstr "" ...@@ -32541,9 +32538,6 @@ msgstr ""
msgid "The invitation was successfully resent." msgid "The invitation was successfully resent."
msgstr "" 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..." msgid "The issue was successfully promoted to an epic. Redirecting to epic..."
msgstr "" msgstr ""
...@@ -32643,9 +32637,6 @@ msgstr "" ...@@ -32643,9 +32637,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." 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 "" 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." msgid "The private key to use when a client certificate is provided. This value is encrypted at rest."
msgstr "" msgstr ""
...@@ -32709,9 +32700,6 @@ msgstr "" ...@@ -32709,9 +32700,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}." 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 "" 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)." 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 "" msgstr ""
...@@ -32736,9 +32724,6 @@ msgstr "" ...@@ -32736,9 +32724,6 @@ msgstr ""
msgid "The specified tab is invalid, please select another" msgid "The specified tab is invalid, please select another"
msgstr "" 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." msgid "The start date must be ealier than the end date."
msgstr "" msgstr ""
...@@ -32751,9 +32736,6 @@ msgstr "" ...@@ -32751,9 +32736,6 @@ msgstr ""
msgid "The tag name can't be changed for an existing release." msgid "The tag name can't be changed for an existing release."
msgstr "" 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." msgid "The time taken by each data entry gathered by that stage."
msgstr "" 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