Commit 6a4f0f8c authored by Phil Hughes's avatar Phil Hughes

Merge branch '13076-hide-and-remove-cycle-analytics-stages' into 'master'

Hide default stages and remove custom stages in cycle analytics

See merge request gitlab-org/gitlab!18703
parents df643b2b bba85395
......@@ -100,8 +100,10 @@ export default {
'hideCustomStageForm',
'showCustomStageForm',
'setDateRange',
'createCustomStage',
'fetchTasksByTypeData',
'createCustomStage',
'updateStage',
'removeStage',
]),
onGroupSelect(group) {
this.setSelectedGroup(group);
......@@ -128,6 +130,12 @@ export default {
onCreateCustomStage(data) {
this.createCustomStage(data);
},
onUpdateStage(data) {
this.updateStage(data);
},
onRemoveStage(id) {
this.removeStage(id);
},
},
groupsQueryParams: {
min_access_level: featureAccessLevel.EVERYONE,
......@@ -225,6 +233,8 @@ export default {
@selectStage="onStageSelect"
@showAddStageForm="onShowAddStageForm"
@submit="onCreateCustomStage"
@hideStage="onUpdateStage"
@removeStage="onRemoveStage"
/>
</div>
</div>
......
......@@ -56,7 +56,7 @@ export default {
handleSelectStage(e) {
// we don't want to emit the select event when we click the more actions dropdown
// But we should still trigger the event if we click anywhere else in the list item
if (!this.$refs.dropdown.contains(e.target)) {
if (this.$refs.dropdown && !this.$refs.dropdown.contains(e.target)) {
this.$emit('select');
}
},
......
......@@ -7,6 +7,7 @@ import StageEventList from './stage_event_list.vue';
import StageTableHeader from './stage_table_header.vue';
import AddStageButton from './add_stage_button.vue';
import CustomStageForm from './custom_stage_form.vue';
import { STAGE_ACTIONS } from '../constants';
export default {
name: 'StageTable',
......@@ -110,6 +111,15 @@ export default {
];
},
},
methods: {
// TODO: DRY These up
hideStage(stageId) {
this.$emit(STAGE_ACTIONS.HIDE, { id: stageId, hidden: true });
},
removeStage(stageId) {
this.$emit(STAGE_ACTIONS.REMOVE, stageId);
},
},
};
</script>
<template>
......@@ -138,8 +148,11 @@ export default {
:title="stage.title"
:value="stage.value"
:is-active="!isAddingCustomStage && stage.id === currentStage.id"
:can-edit="canEditStages"
:is-default-stage="!stage.custom"
@select="$emit('selectStage', stage)"
@remove="removeStage(stage.id)"
@hide="hideStage(stage.id)"
/>
<add-stage-button
v-if="canEditStages"
......
......@@ -32,3 +32,10 @@ export const EMPTY_STAGE_TEXT = {
export const TASKS_BY_TYPE_SUBJECT_ISSUE = 'Issue';
export const TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST = 'MergeRequest';
export const STAGE_ACTIONS = {
EDIT: 'editStage',
REMOVE: 'removeStage',
SAVE: 'saveStage',
HIDE: 'hideStage',
};
......@@ -239,3 +239,52 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => {
}
return Promise.resolve();
};
export const requestUpdateStage = ({ commit }) => commit(types.REQUEST_UPDATE_STAGE);
export const receiveUpdateStageSuccess = ({ commit, dispatch }) => {
commit(types.RECEIVE_UPDATE_STAGE_RESPONSE);
createFlash(__(`Stage data updated`), 'notice');
dispatch('fetchCycleAnalyticsData');
};
export const receiveUpdateStageError = ({ commit }) => {
commit(types.RECEIVE_UPDATE_STAGE_RESPONSE);
createFlash(__('There was a problem saving your custom stage, please try again'));
};
export const updateStage = ({ dispatch, state }, { id, ...rest }) => {
const {
selectedGroup: { fullPath },
} = state;
dispatch('requestUpdateStage');
return Api.cycleAnalyticsUpdateStage(id, fullPath, { ...rest })
.then(({ data }) => dispatch('receiveUpdateStageSuccess', data))
.catch(error => dispatch('receiveUpdateStageError', error));
};
export const requestRemoveStage = ({ commit }) => commit(types.REQUEST_REMOVE_STAGE);
export const receiveRemoveStageSuccess = ({ commit, dispatch }) => {
commit(types.RECEIVE_REMOVE_STAGE_RESPONSE);
createFlash(__('Stage removed'), 'notice');
dispatch('fetchCycleAnalyticsData');
};
export const receiveRemoveStageError = ({ commit }) => {
commit(types.RECEIVE_REMOVE_STAGE_RESPONSE);
createFlash(__('There was an error removing your custom stage, please try again'));
};
export const removeStage = ({ dispatch, state }, stageId) => {
const {
selectedGroup: { fullPath },
} = state;
dispatch('requestRemoveStage');
return Api.cycleAnalyticsRemoveStage(stageId, fullPath)
.then(() => dispatch('receiveRemoveStageSuccess'))
.catch(error => dispatch('receiveRemoveStageError', error));
};
......@@ -27,12 +27,18 @@ export const REQUEST_GROUP_STAGES_AND_EVENTS = 'REQUEST_GROUP_STAGES_AND_EVENTS'
export const RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS = 'RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS';
export const RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR = 'RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR';
export const REQUEST_CREATE_CUSTOM_STAGE = 'REQUEST_CREATE_CUSTOM_STAGE';
export const RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE = 'RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE';
export const SET_TASKS_BY_TYPE_SUBJECT = 'SET_TASKS_BY_TYPE_SUBJECT';
export const SET_TASKS_BY_TYPE_LABELS = 'SET_TASKS_BY_TYPE_LABELS';
export const REQUEST_TASKS_BY_TYPE_DATA = 'REQUEST_TASKS_BY_TYPE_DATA';
export const RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS = 'RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS';
export const RECEIVE_TASKS_BY_TYPE_DATA_ERROR = 'RECEIVE_TASKS_BY_TYPE_DATA_ERROR';
export const REQUEST_CREATE_CUSTOM_STAGE = 'REQUEST_CREATE_CUSTOM_STAGE';
export const RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE = 'RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE';
export const REQUEST_UPDATE_STAGE = 'REQUEST_UPDATE_STAGE';
export const RECEIVE_UPDATE_STAGE_RESPONSE = 'RECEIVE_UPDATE_STAGE_RESPONSE';
export const REQUEST_REMOVE_STAGE = 'REQUEST_REMOVE_STAGE';
export const RECEIVE_REMOVE_STAGE_RESPONSE = 'RECEIVE_REMOVE_STAGE_RESPONSE';
......@@ -111,7 +111,7 @@ export default {
},
[types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS](state, data) {
const { events = [], stages = [] } = data;
state.stages = transformRawStages(stages);
state.stages = transformRawStages(stages.filter(({ hidden = false }) => !hidden));
state.customStageFormEvents = events.map(ev =>
convertObjectPropsToCamelCase(ev, { deep: true }),
......@@ -122,12 +122,6 @@ export default {
state.selectedStageId = id;
}
},
[types.REQUEST_CREATE_CUSTOM_STAGE](state) {
state.isSavingCustomStage = true;
},
[types.RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE](state) {
state.isSavingCustomStage = false;
},
[types.REQUEST_TASKS_BY_TYPE_DATA](state) {
state.isLoadingChartData = true;
},
......@@ -141,4 +135,22 @@ export default {
data,
};
},
[types.REQUEST_CREATE_CUSTOM_STAGE](state) {
state.isSavingCustomStage = true;
},
[types.RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE](state) {
state.isSavingCustomStage = false;
},
[types.REQUEST_UPDATE_STAGE](state) {
state.isLoading = true;
},
[types.RECEIVE_UPDATE_STAGE_RESPONSE](state) {
state.isLoading = false;
},
[types.REQUEST_REMOVE_STAGE](state) {
state.isLoading = true;
},
[types.RECEIVE_REMOVE_STAGE_RESPONSE](state) {
state.isLoading = false;
},
};
......@@ -22,6 +22,7 @@ export default {
cycleAnalyticsSummaryDataPath: '/groups/:group_id/-/cycle_analytics',
cycleAnalyticsGroupStagesAndEventsPath: '/-/analytics/cycle_analytics/stages',
cycleAnalyticsStageEventsPath: '/groups/:group_id/-/cycle_analytics/events/:stage_id.json',
cycleAnalyticsStagePath: '/-/analytics/cycle_analytics/stages/:stage_id',
userSubscription(namespaceId) {
const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId));
......@@ -173,4 +174,24 @@ export default {
params: { group_id: groupId },
});
},
cycleAnalyticsStageUrl(stageId) {
return Api.buildUrl(this.cycleAnalyticsStagePath).replace(':stage_id', stageId);
},
cycleAnalyticsUpdateStage(stageId, groupId, data) {
const url = this.cycleAnalyticsStageUrl(stageId);
return axios.put(url, data, {
params: { group_id: groupId },
});
},
cycleAnalyticsRemoveStage(stageId, groupId) {
const url = this.cycleAnalyticsStageUrl(stageId);
return axios.delete(url, {
params: { group_id: groupId },
});
},
};
......@@ -33,9 +33,7 @@ describe 'Group Cycle Analytics', :js do
context 'displays correct fields after group selection' do
before do
dropdown = page.find('.dropdown-groups')
dropdown.click
dropdown.find('a').click
select_group
end
it 'hides the empty state' do
......@@ -216,6 +214,10 @@ describe 'Group Cycle Analytics', :js do
describe 'Customizable cycle analytics', :js do
let(:button_class) { '.js-add-stage-button' }
def select_dropdown_option(name, elem = "option", index = 1)
page.find("select[name='#{name}']").all(elem)[index].select_option
end
context 'enabled' do
before do
select_group
......@@ -314,6 +316,86 @@ describe 'Group Cycle Analytics', :js do
end
end
end
context 'Stage table' do
custom_stage = "Cool beans"
let(:params) { { name: custom_stage, start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged } }
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).ancestor(".stage-nav-item") }
def create_custom_stage
Analytics::CycleAnalytics::Stages::CreateService.new(parent: group, params: params, current_user: user).execute
end
def toggle_more_options(stage)
stage.hover
stage.find(".more-actions-toggle").click
end
context 'default stages' do
before do
select_group
toggle_more_options(first_default_stage)
end
it 'can be hidden' do
expect(first_default_stage.find('.more-actions-dropdown')).to have_text "Hide stage"
end
it 'can not be edited' do
expect(first_default_stage.find('.more-actions-dropdown')).not_to have_text "Edit stage"
end
it 'can not be removed' do
expect(first_default_stage.find('.more-actions-dropdown')).not_to have_text "Remove stage"
end
it 'will not appear in the stage table after being hidden' do
nav = page.find('.stage-nav')
expect(nav).to have_text("Issue")
click_button "Hide stage"
expect(page.find('.flash-notice')).to have_text 'Stage data updated'
expect(nav).not_to have_text("Issue")
end
end
context 'custom stages' do
before do
create_custom_stage
select_group
expect(page).to have_text custom_stage
toggle_more_options(first_custom_stage)
end
it 'can not be hidden' do
expect(first_custom_stage.find('.more-actions-dropdown')).not_to have_text "Hide stage"
end
it 'can be edited' do
expect(first_custom_stage.find('.more-actions-dropdown')).to have_text "Edit stage"
end
it 'can be removed' do
expect(first_custom_stage.find('.more-actions-dropdown')).to have_text "Remove stage"
end
it 'will not appear in the stage table after being removed' do
nav = page.find('.stage-nav')
expect(nav).to have_text(custom_stage)
click_button "Remove stage"
expect(page.find('.flash-notice')).to have_text 'Stage removed'
expect(nav).not_to have_text(custom_stage)
end
end
end
end
context 'not enabled' do
......
......@@ -34,6 +34,7 @@ function createComponent(props = {}, shallow = false) {
currentStageEvents: issueEvents,
labels: groupLabels,
isLoading: false,
isLoadingSummaryData: false,
isEmptyStage: false,
isAddingCustomStage: false,
isSavingCustomStage: false,
......
......@@ -26,11 +26,13 @@ const endpoints = {
stageData: `/groups/${group.path}/-/cycle_analytics/events/${selectedStageSlug}.json`,
};
const stageEndpoint = ({ stageId }) => `/-/analytics/cycle_analytics/stages/${stageId}`;
describe('Cycle analytics actions', () => {
let state;
let mock;
function shouldFlashAnError(msg = flashErrorMessage) {
function shouldFlashAMessage(msg = flashErrorMessage) {
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(msg);
}
......@@ -160,7 +162,7 @@ describe('Cycle analytics actions', () => {
commit: () => {},
});
shouldFlashAnError('There was an error fetching data for the selected stage');
shouldFlashAMessage('There was an error fetching data for the selected stage');
});
});
......@@ -213,7 +215,7 @@ describe('Cycle analytics actions', () => {
commit: () => {},
});
shouldFlashAnError('There was an error fetching label data for the selected group');
shouldFlashAMessage('There was an error fetching label data for the selected group');
});
});
});
......@@ -286,7 +288,7 @@ describe('Cycle analytics actions', () => {
commit: () => {},
})
.then(() => {
shouldFlashAnError('There was an error while fetching cycle analytics summary data.');
shouldFlashAMessage('There was an error while fetching cycle analytics summary data.');
done();
})
.catch(done.fail);
......@@ -312,7 +314,7 @@ describe('Cycle analytics actions', () => {
commit: () => {},
})
.then(() => {
shouldFlashAnError('There was an error fetching cycle analytics stages.');
shouldFlashAMessage('There was an error fetching cycle analytics stages.');
done();
})
.catch(done.fail);
......@@ -376,7 +378,7 @@ describe('Cycle analytics actions', () => {
{},
);
shouldFlashAnError();
shouldFlashAMessage();
});
});
});
......@@ -429,7 +431,7 @@ describe('Cycle analytics actions', () => {
{ response },
);
shouldFlashAnError();
shouldFlashAMessage();
});
});
......@@ -483,9 +485,178 @@ describe('Cycle analytics actions', () => {
},
{},
);
});
shouldFlashAnError();
shouldFlashAMessage();
});
});
describe('updateStage', () => {
const stageId = 'cool-stage';
const payload = { hidden: true };
beforeEach(() => {
mock.onPut(stageEndpoint({ stageId }), payload).replyOnce(200, payload);
state = { selectedGroup };
});
it('dispatches receiveUpdateStageSuccess with put request response data', done => {
testAction(
actions.updateStage,
{
id: stageId,
...payload,
},
state,
[],
[
{ type: 'requestUpdateStage' },
{
type: 'receiveUpdateStageSuccess',
payload,
},
],
done,
);
});
describe('with a failed request', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
mock = new MockAdapter(axios);
mock.onPut(stageEndpoint({ stageId })).replyOnce(404);
});
it('dispatches receiveUpdateStageError', done => {
testAction(
actions.updateStage,
{
id: stageId,
...payload,
},
state,
[],
[
{ type: 'requestUpdateStage' },
{
type: 'receiveUpdateStageError',
payload: error,
},
],
done,
);
});
it('flashes an error message', done => {
actions.receiveUpdateStageError(
{
commit: () => {},
state,
},
{},
);
shouldFlashAMessage('There was a problem saving your custom stage, please try again');
done();
});
});
});
describe('removeStage', () => {
const stageId = 'cool-stage';
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
mock.onDelete(stageEndpoint({ stageId })).replyOnce(200);
state = { selectedGroup };
});
it('dispatches receiveRemoveStageSuccess with put request response data', done => {
testAction(
actions.removeStage,
stageId,
state,
[],
[
{ type: 'requestRemoveStage' },
{
type: 'receiveRemoveStageSuccess',
},
],
done,
);
});
describe('with a failed request', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onDelete(stageEndpoint({ stageId })).replyOnce(404);
});
it('dispatches receiveRemoveStageError', done => {
testAction(
actions.removeStage,
stageId,
state,
[],
[
{ type: 'requestRemoveStage' },
{
type: 'receiveRemoveStageError',
payload: error,
},
],
done,
);
});
it('flashes an error message', done => {
actions.receiveRemoveStageError(
{
commit: () => {},
state,
},
{},
);
shouldFlashAMessage('There was an error removing your custom stage, please try again');
done();
});
});
});
describe('receiveRemoveStageSuccess', () => {
const stageId = 'cool-stage';
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
mock.onDelete(stageEndpoint({ stageId })).replyOnce(200);
state = { selectedGroup };
});
it('dispatches fetchCycleAnalyticsData', done => {
testAction(
actions.receiveRemoveStageSuccess,
stageId,
state,
[{ type: 'RECEIVE_REMOVE_STAGE_RESPONSE' }],
[{ type: 'fetchCycleAnalyticsData' }],
done,
);
});
it('flashes a success message', done => {
actions.receiveRemoveStageSuccess(
{
dispatch: () => {},
commit: () => {},
state,
},
{},
);
shouldFlashAMessage('Stage removed');
done();
});
});
});
......@@ -50,6 +50,10 @@ describe('Cycle analytics mutations', () => {
${types.RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE} | ${'isSavingCustomStage'} | ${false}
${types.REQUEST_TASKS_BY_TYPE_DATA} | ${'isLoadingChartData'} | ${true}
${types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR} | ${'isLoadingChartData'} | ${false}
${types.REQUEST_UPDATE_STAGE} | ${'isLoading'} | ${true}
${types.RECEIVE_UPDATE_STAGE_RESPONSE} | ${'isLoading'} | ${false}
${types.REQUEST_REMOVE_STAGE} | ${'isLoading'} | ${true}
${types.RECEIVE_REMOVE_STAGE_RESPONSE} | ${'isLoading'} | ${false}
`('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => {
mutations[mutation](state);
......@@ -195,6 +199,23 @@ describe('Cycle analytics mutations', () => {
{ slug: 'test', value: null },
]);
});
describe('with hidden stages', () => {
const mockStages = customizableStagesAndEvents.stages;
beforeEach(() => {
mockStages[0].hidden = true;
mutations[types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS](state, {
...customizableStagesAndEvents.events,
stages: mockStages,
});
});
it('will only return stages that are not hidden', () => {
expect(state.stages.map(({ id }) => id)).not.toContain(mockStages[0].id);
});
});
});
describe(`${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR}`, () => {
......
......@@ -437,5 +437,45 @@ describe('Api', () => {
.catch(done.fail);
});
});
describe('cycleAnalyticsUpdateStage', () => {
it('updates the stage data', done => {
const response = { id: stageId, custom: false, hidden: true, name: 'nice-stage' };
const stageData = {
name: 'nice-stage',
hidden: true,
};
const expectedUrl = `${dummyUrlRoot}/-/analytics/cycle_analytics/stages/${stageId}`;
mock.onPut(expectedUrl).reply(200, response);
Api.cycleAnalyticsUpdateStage(stageId, groupId, stageData)
.then(({ data, config: { params: reqParams, data: reqData, url } }) => {
expect(data).toEqual(response);
expect(reqParams).toEqual({ group_id: groupId });
expect(JSON.parse(reqData)).toMatchObject(stageData);
expect(url).toEqual(expectedUrl);
})
.then(done)
.catch(done.fail);
});
});
describe('cycleAnalyticsRemoveStage', () => {
it('deletes the specified data', done => {
const response = { id: stageId, hidden: true, custom: true };
const expectedUrl = `${dummyUrlRoot}/-/analytics/cycle_analytics/stages/${stageId}`;
mock.onDelete(expectedUrl).reply(200, response);
Api.cycleAnalyticsRemoveStage(stageId, groupId)
.then(({ data, config: { params: reqParams, url } }) => {
expect(data).toEqual(response);
expect(reqParams).toEqual({ group_id: groupId });
expect(url).toEqual(expectedUrl);
})
.then(done)
.catch(done.fail);
});
});
});
});
......@@ -16427,6 +16427,12 @@ msgstr ""
msgid "Stage changes"
msgstr ""
msgid "Stage data updated"
msgstr ""
msgid "Stage removed"
msgstr ""
msgid "Staged"
msgstr ""
......@@ -17536,6 +17542,9 @@ msgstr ""
msgid "There was an error removing the e-mail."
msgstr ""
msgid "There was an error removing your custom stage, please try again"
msgstr ""
msgid "There was an error resetting group pipeline minutes."
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