Commit 9d45cf50 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'feat-add-recover-hidden-stages-dropdown' into 'master'

Recover hidden cycle analytics stages

See merge request gitlab-org/gitlab!25309
parents 8f91c1e6 6fe5b635
...@@ -192,7 +192,7 @@ ...@@ -192,7 +192,7 @@
.stage-events { .stage-events {
width: 60%; width: 60%;
overflow: scroll; overflow: scroll;
height: 467px; min-height: 467px;
} }
.stage-event-list { .stage-event-list {
......
...@@ -76,6 +76,7 @@ export default { ...@@ -76,6 +76,7 @@ export default {
'durationChartPlottableData', 'durationChartPlottableData',
'tasksByTypeChartData', 'tasksByTypeChartData',
'durationChartMedianData', 'durationChartMedianData',
'activeStages',
]), ]),
shouldRenderEmptyState() { shouldRenderEmptyState() {
return !this.selectedGroup; return !this.selectedGroup;
...@@ -271,7 +272,7 @@ export default { ...@@ -271,7 +272,7 @@ export default {
v-if="selectedStage" v-if="selectedStage"
class="js-stage-table" class="js-stage-table"
:current-stage="selectedStage" :current-stage="selectedStage"
:stages="stages" :stages="activeStages"
:medians="medians" :medians="medians"
:is-loading="isLoadingStage" :is-loading="isLoadingStage"
:is-empty-stage="isEmptyStage" :is-empty-stage="isEmptyStage"
......
<script> <script>
import { mapGetters } from 'vuex';
import { isEqual } from 'underscore'; import { isEqual } from 'underscore';
import { GlFormGroup, GlFormInput, GlFormSelect, GlLoadingIcon } from '@gitlab/ui'; import {
GlFormGroup,
GlFormInput,
GlFormSelect,
GlLoadingIcon,
GlDropdown,
GlDropdownHeader,
GlDropdownItem,
} from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import LabelsSelector from './labels_selector.vue'; import LabelsSelector from './labels_selector.vue';
...@@ -30,6 +39,9 @@ export default { ...@@ -30,6 +39,9 @@ export default {
GlFormSelect, GlFormSelect,
GlLoadingIcon, GlLoadingIcon,
LabelsSelector, LabelsSelector,
GlDropdown,
GlDropdownHeader,
GlDropdownItem,
}, },
props: { props: {
events: { events: {
...@@ -79,6 +91,7 @@ export default { ...@@ -79,6 +91,7 @@ export default {
}; };
}, },
computed: { computed: {
...mapGetters(['hiddenStages']),
startEventOptions() { startEventOptions() {
return [ return [
{ value: null, text: s__('CustomCycleAnalytics|Select start event') }, { value: null, text: s__('CustomCycleAnalytics|Select start event') },
...@@ -148,6 +161,9 @@ export default { ...@@ -148,6 +161,9 @@ export default {
? s__('CustomCycleAnalytics|Editing stage') ? s__('CustomCycleAnalytics|Editing stage')
: s__('CustomCycleAnalytics|New stage'); : s__('CustomCycleAnalytics|New stage');
}, },
hasHiddenStages() {
return this.hiddenStages.length;
},
}, },
watch: { watch: {
initialFields(newFields) { initialFields(newFields) {
...@@ -202,13 +218,28 @@ export default { ...@@ -202,13 +218,28 @@ export default {
onUpdateEndEventField() { onUpdateEndEventField() {
this.$set(this.fieldErrors, 'endEventIdentifier', null); this.$set(this.fieldErrors, 'endEventIdentifier', null);
}, },
handleRecoverStage(id) {
this.$emit(STAGE_ACTIONS.UPDATE, { id, hidden: false });
},
}, },
}; };
</script> </script>
<template> <template>
<form class="custom-stage-form m-4 mt-0"> <form class="custom-stage-form m-4 mt-0">
<div class="mb-1"> <div class="mb-1 d-flex flex-row justify-content-between">
<h4>{{ formTitle }}</h4> <h4>{{ formTitle }}</h4>
<gl-dropdown :text="__('Recover hidden stage')" class="js-recover-hidden-stage-dropdown">
<gl-dropdown-header>{{ __('Default stages') }}</gl-dropdown-header>
<template v-if="hasHiddenStages">
<gl-dropdown-item
v-for="stage in hiddenStages"
:key="stage.id"
@click="handleRecoverStage(stage.id)"
>{{ stage.title }}</gl-dropdown-item
>
</template>
<p v-else class="mx-3 my-2">{{ __('All default stages are currently visible') }}</p>
</gl-dropdown>
</div> </div>
<gl-form-group <gl-form-group
......
...@@ -48,3 +48,9 @@ export const tasksByTypeChartData = ({ tasksByType, startDate, endDate }) => { ...@@ -48,3 +48,9 @@ export const tasksByTypeChartData = ({ tasksByType, startDate, endDate }) => {
} }
return { groupBy: [], data: [], seriesNames: [] }; return { groupBy: [], data: [], seriesNames: [] };
}; };
const filterStagesByHiddenStatus = (stages = [], isHidden = true) =>
stages.filter(({ hidden = false }) => hidden === isHidden);
export const hiddenStages = ({ stages }) => filterStagesByHiddenStatus(stages);
export const activeStages = ({ stages }) => filterStagesByHiddenStatus(stages, false);
...@@ -137,7 +137,7 @@ export default { ...@@ -137,7 +137,7 @@ export default {
}, },
[types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS](state, data) { [types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS](state, data) {
const { events = [], stages = [] } = data; const { events = [], stages = [] } = data;
state.stages = transformRawStages(stages.filter(({ hidden = false }) => !hidden)); state.stages = transformRawStages(stages);
state.customStageFormEvents = events.map(ev => state.customStageFormEvents = events.map(ev =>
convertObjectPropsToCamelCase(ev, { deep: true }), convertObjectPropsToCamelCase(ev, { deep: true }),
......
...@@ -299,7 +299,7 @@ describe 'Group Value Stream Analytics', :js do ...@@ -299,7 +299,7 @@ describe 'Group Value Stream Analytics', :js do
start_label_event = :issue_label_added start_label_event = :issue_label_added
stop_label_event = :issue_label_removed stop_label_event = :issue_label_removed
let(:button_class) { '.js-add-stage-button' } 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 } } let(:params) { { name: custom_stage_name, start_event_identifier: start_event_identifier, end_event_identifier: end_event_identifier } }
let(:first_default_stage) { page.find('.stage-nav-item-cell', text: "Issue").ancestor(".stage-nav-item") } let(:first_default_stage) { page.find('.stage-nav-item-cell', text: "Issue").ancestor(".stage-nav-item") }
let(:first_custom_stage) { page.find('.stage-nav-item-cell', text: custom_stage_name).ancestor(".stage-nav-item") } let(:first_custom_stage) { page.find('.stage-nav-item-cell', text: custom_stage_name).ancestor(".stage-nav-item") }
...@@ -334,34 +334,34 @@ describe 'Group Value Stream Analytics', :js do ...@@ -334,34 +334,34 @@ describe 'Group Value Stream Analytics', :js do
context 'Add a stage button' do context 'Add a stage button' do
it 'is visible' do it 'is visible' do
expect(page).to have_selector(button_class, visible: true) expect(page).to have_selector(add_stage_button, visible: true)
expect(page).to have_text('Add a stage') expect(page).to have_text('Add a stage')
end end
it 'becomes active when clicked' do it 'becomes active when clicked' do
expect(page).not_to have_selector("#{button_class}.active") expect(page).not_to have_selector("#{add_stage_button}.active")
find(button_class).click find(add_stage_button).click
expect(page).to have_selector("#{button_class}.active") expect(page).to have_selector("#{add_stage_button}.active")
end end
it 'displays the custom stage form when clicked' do it 'displays the custom stage form when clicked' do
expect(page).not_to have_text('New stage') expect(page).not_to have_text('New stage')
page.find(button_class).click page.find(add_stage_button).click
expect(page).to have_text('New stage') expect(page).to have_text('New stage')
end end
end end
context 'Custom stage form' do context 'Custom stage form' do
let(:show_form_button_class) { '.js-add-stage-button' } let(:show_form_add_stage_button) { '.js-add-stage-button' }
before do before do
select_group select_group
page.find(show_form_button_class).click page.find(show_form_add_stage_button).click
wait_for_requests wait_for_requests
end end
...@@ -535,6 +535,25 @@ describe 'Group Value Stream Analytics', :js do ...@@ -535,6 +535,25 @@ describe 'Group Value Stream Analytics', :js do
context 'Stage table' do context 'Stage table' do
context 'default stages' do context 'default stages' do
let(:nav) { page.find(stage_nav_selector) }
def open_recover_stage_dropdown
find(add_stage_button).click
expect(page).to have_content('New stage')
expect(page).to have_content('Recover hidden stage')
click_button "Recover hidden stage"
within(:css, '.js-recover-hidden-stage-dropdown') do
expect(find(".dropdown-menu")).to have_content('Default stages')
end
end
def active_stages
page.all(".stage-nav .stage-name").collect(&:text)
end
before do before do
select_group select_group
...@@ -553,14 +572,43 @@ describe 'Group Value Stream Analytics', :js do ...@@ -553,14 +572,43 @@ describe 'Group Value Stream Analytics', :js do
expect(first_default_stage.find('.more-actions-dropdown')).not_to have_text "Remove stage" expect(first_default_stage.find('.more-actions-dropdown')).not_to have_text "Remove stage"
end end
it 'will not appear in the stage table after being hidden' do context 'hidden' do
nav = page.find(stage_nav_selector) before do
expect(nav).to have_text("Issue") click_button "Hide stage"
# wait for the stage list to laod
expect(nav).to have_content("Plan")
end
it 'will not appear in the stage table' do
expect(active_stages).not_to include("Issue")
end
it 'can be recovered' do
open_recover_stage_dropdown
expect(page.find('.js-recover-hidden-stage-dropdown')).to have_text('Issue')
end
end
context 'recovered' do
before do
click_button "Hide stage" click_button "Hide stage"
expect(page.find('.flash-notice')).to have_text 'Stage data updated' # wait for the stage list to laod
expect(nav).not_to have_text("Issue") expect(nav).to have_content("Plan")
end
it 'will appear in the stage table' do
open_recover_stage_dropdown
click_button("Issue")
# wait for the stage list to laod
expect(nav).to have_content("Plan")
expect(page.find('.flash-notice')).to have_content 'Stage data updated'
expect(active_stages).to include("Issue")
end
end end
end end
......
...@@ -7,7 +7,7 @@ exports[`CustomStageForm Editing a custom stage isSavingCustomStage=true display ...@@ -7,7 +7,7 @@ exports[`CustomStageForm Editing a custom stage isSavingCustomStage=true display
`; `;
exports[`CustomStageForm Start event with events does not select events with canBeStartEvent=false for the start events dropdown 1`] = ` exports[`CustomStageForm Start event with events does not select events with canBeStartEvent=false for the start events dropdown 1`] = `
"<select name=\\"custom-stage-start-event\\" required=\\"required\\" aria-required=\\"true\\" class=\\"gl-form-select custom-select\\" id=\\"__BVID__123\\"> "<select name=\\"custom-stage-start-event\\" required=\\"required\\" aria-required=\\"true\\" class=\\"gl-form-select custom-select\\" id=\\"__BVID__177\\">
<option value=\\"\\">Select start event</option> <option value=\\"\\">Select start event</option>
<option value=\\"issue_created\\">Issue created</option> <option value=\\"issue_created\\">Issue created</option>
<option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option> <option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option>
...@@ -30,7 +30,7 @@ exports[`CustomStageForm Start event with events does not select events with can ...@@ -30,7 +30,7 @@ exports[`CustomStageForm Start event with events does not select events with can
`; `;
exports[`CustomStageForm Start event with events selects events with canBeStartEvent=true for the start events dropdown 1`] = ` exports[`CustomStageForm Start event with events selects events with canBeStartEvent=true for the start events dropdown 1`] = `
"<select name=\\"custom-stage-start-event\\" required=\\"required\\" aria-required=\\"true\\" class=\\"gl-form-select custom-select\\" id=\\"__BVID__95\\"> "<select name=\\"custom-stage-start-event\\" required=\\"required\\" aria-required=\\"true\\" class=\\"gl-form-select custom-select\\" id=\\"__BVID__137\\">
<option value=\\"\\">Select start event</option> <option value=\\"\\">Select start event</option>
<option value=\\"issue_created\\">Issue created</option> <option value=\\"issue_created\\">Issue created</option>
<option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option> <option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option>
......
import Vue from 'vue'; import Vue from 'vue';
import { mount } from '@vue/test-utils'; import Vuex from 'vuex';
import createStore from 'ee/analytics/cycle_analytics/store';
import { createLocalVue, mount } from '@vue/test-utils';
import CustomStageForm from 'ee/analytics/cycle_analytics/components/custom_stage_form.vue'; import CustomStageForm from 'ee/analytics/cycle_analytics/components/custom_stage_form.vue';
import { STAGE_ACTIONS } from 'ee/analytics/cycle_analytics/constants'; import { STAGE_ACTIONS } from 'ee/analytics/cycle_analytics/constants';
import { import {
...@@ -21,14 +23,22 @@ const initData = { ...@@ -21,14 +23,22 @@ const initData = {
endEventLabelId: groupLabels[1].id, endEventLabelId: groupLabels[1].id,
}; };
let store = null;
const localVue = createLocalVue();
localVue.use(Vuex);
describe('CustomStageForm', () => { describe('CustomStageForm', () => {
function createComponent(props) { function createComponent(props = {}, stubs = {}) {
store = createStore();
return mount(CustomStageForm, { return mount(CustomStageForm, {
localVue,
store,
propsData: { propsData: {
events, events,
labels: groupLabels, labels: groupLabels,
...props, ...props,
}, },
stubs,
}); });
} }
...@@ -44,6 +54,9 @@ describe('CustomStageForm', () => { ...@@ -44,6 +54,9 @@ describe('CustomStageForm', () => {
submit: '.js-save-stage', submit: '.js-save-stage',
cancel: '.js-save-stage-cancel', cancel: '.js-save-stage-cancel',
invalidFeedback: '.invalid-feedback', 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',
}; };
function getDropdownOption(_wrapper, dropdown, index) { function getDropdownOption(_wrapper, dropdown, index) {
...@@ -122,7 +135,7 @@ describe('CustomStageForm', () => { ...@@ -122,7 +135,7 @@ describe('CustomStageForm', () => {
describe('start event label', () => { describe('start event label', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({}, false); wrapper = createComponent();
}); });
afterEach(() => { afterEach(() => {
...@@ -174,7 +187,7 @@ describe('CustomStageForm', () => { ...@@ -174,7 +187,7 @@ describe('CustomStageForm', () => {
const currAllowed = startEvents[startEventArrayIndex].allowedEndEvents; const currAllowed = startEvents[startEventArrayIndex].allowedEndEvents;
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({}, false); wrapper = createComponent();
}); });
it('notifies that a start event needs to be selected first', () => { it('notifies that a start event needs to be selected first', () => {
...@@ -251,7 +264,7 @@ describe('CustomStageForm', () => { ...@@ -251,7 +264,7 @@ describe('CustomStageForm', () => {
describe('with a stop event selected and a change to the start event', () => { describe('with a stop event selected and a change to the start event', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({}); wrapper = createComponent();
wrapper.setData({ wrapper.setData({
fields: { fields: {
...@@ -518,15 +531,12 @@ describe('CustomStageForm', () => { ...@@ -518,15 +531,12 @@ describe('CustomStageForm', () => {
describe('Editing a custom stage', () => { describe('Editing a custom stage', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent( wrapper = createComponent({
{
isEditingCustomStage: true, isEditingCustomStage: true,
initialFields: { initialFields: {
...initData, ...initData,
}, },
}, });
false,
);
wrapper.setData({ wrapper.setData({
fields: { fields: {
...@@ -682,4 +692,61 @@ describe('CustomStageForm', () => { ...@@ -682,4 +692,61 @@ describe('CustomStageForm', () => {
expect(wrapper.find({ ref: 'startEventIdentifier' }).html()).toContain('cant be blank'); expect(wrapper.find({ ref: 'startEventIdentifier' }).html()).toContain('cant be blank');
}); });
}); });
describe('recover stage dropdown', () => {
const formFieldStubs = {
'gl-form-group': true,
'gl-form-select': true,
'labels-selector': true,
};
beforeEach(() => {
wrapper = createComponent({}, formFieldStubs);
});
describe('without hidden stages', () => {
it('has the recover stage dropdown', () => {
expect(wrapper.find(sel.recoverStageDropdown).exists()).toBe(true);
});
it('has no stages available to recover', () => {
wrapper.find(sel.recoverStageDropdownTrigger).trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(sel.recoverStageDropdown).text()).toContain(
'All default stages are currently visible',
);
});
});
});
describe('with hidden stages', () => {
beforeEach(() => {
wrapper = createComponent({}, formFieldStubs);
store.state.stages = [{ id: 'my-stage', title: 'My default stage', hidden: true }];
});
it('has stages available to recover', () => {
wrapper.find(sel.recoverStageDropdownTrigger).trigger('click');
return wrapper.vm.$nextTick().then(() => {
const txt = wrapper.find(sel.recoverStageDropdown).text();
expect(txt).not.toContain('All default stages are currently visible');
expect(txt).toContain('My default stage');
});
});
it(`emits the ${STAGE_ACTIONS.UPDATE} action when clicking on a stage to recover`, () => {
wrapper.find(sel.recoverStageDropdownTrigger).trigger('click');
return wrapper.vm.$nextTick().then(() => {
wrapper
.findAll(sel.hiddenStageDropdownOption)
.at(0)
.trigger('click');
expect(wrapper.emitted()).toEqual({
[STAGE_ACTIONS.UPDATE]: [[{ hidden: false, id: 'my-stage' }]],
});
});
});
});
});
}); });
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
transformedDurationMedianData, transformedDurationMedianData,
durationChartPlottableData, durationChartPlottableData,
durationChartPlottableMedianData, durationChartPlottableMedianData,
allowedStages,
} from '../mock_data'; } from '../mock_data';
let state = null; let state = null;
...@@ -121,4 +122,20 @@ describe('Cycle analytics getters', () => { ...@@ -121,4 +122,20 @@ describe('Cycle analytics getters', () => {
expect(getters.durationChartMedianData(stateWithDurationMedianData)).toEqual([]); expect(getters.durationChartMedianData(stateWithDurationMedianData)).toEqual([]);
}); });
}); });
const hiddenStage = { ...allowedStages[2], hidden: true };
const givenStages = [allowedStages[0], allowedStages[1], hiddenStage];
describe.each`
func | givenStages | expectedStages
${'hiddenStages'} | ${givenStages} | ${[hiddenStage]}
${'activeStages'} | ${givenStages} | ${[allowedStages[0], allowedStages[1]]}
`('hiddenStages', ({ func, expectedStages, givenStages: stages }) => {
it(`'${func}' returns ${expectedStages.length} stages`, () => {
expect(getters[func]({ stages })).toEqual(expectedStages);
});
it(`'${func}' returns an empty array if there are no stages`, () => {
expect(getters[func]({ stages: [] })).toEqual([]);
});
});
}); });
...@@ -1538,6 +1538,9 @@ msgstr "" ...@@ -1538,6 +1538,9 @@ msgstr ""
msgid "All changes are committed" msgid "All changes are committed"
msgstr "" msgstr ""
msgid "All default stages are currently visible"
msgstr ""
msgid "All email addresses will be used to identify your commits." msgid "All email addresses will be used to identify your commits."
msgstr "" msgstr ""
...@@ -6110,6 +6113,9 @@ msgstr "" ...@@ -6110,6 +6113,9 @@ msgstr ""
msgid "Default projects limit" msgid "Default projects limit"
msgstr "" msgstr ""
msgid "Default stages"
msgstr ""
msgid "Default: Directly import the Google Code email address or username" msgid "Default: Directly import the Google Code email address or username"
msgstr "" msgstr ""
...@@ -15727,6 +15733,9 @@ msgstr "" ...@@ -15727,6 +15733,9 @@ msgstr ""
msgid "Recipe" msgid "Recipe"
msgstr "" msgstr ""
msgid "Recover hidden stage"
msgstr ""
msgid "Recovery Codes" msgid "Recovery Codes"
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