Commit 79a8cb9c authored by Ezekiel Kigbo's avatar Ezekiel Kigbo Committed by Phil Hughes

Fetch stages from value stream

Fetch the data for each value stream and
make sure we include the value stream
in the create stage request

Create a stage in the value stream
parent 85aeffd2
......@@ -14,6 +14,7 @@ import { mapState, mapActions } from 'vuex';
import { sprintf, __ } from '~/locale';
import { debounce } from 'lodash';
import { DATA_REFETCH_DELAY } from '../../shared/constants';
import { DEFAULT_VALUE_STREAM_ID } from '../constants';
const ERRORS = {
MIN_LENGTH: __('Name is required'),
......@@ -31,6 +32,10 @@ const validate = ({ name }) => {
return errors;
};
const hasCustomValueStream = vs => {
return Boolean(vs.length > 1 || vs[0].name.toLowerCase().trim() !== DEFAULT_VALUE_STREAM_ID);
};
export default {
components: {
GlButton,
......@@ -48,7 +53,7 @@ export default {
data() {
return {
name: '',
errors: { name: [] },
errors: {},
};
},
computed: {
......@@ -59,13 +64,13 @@ export default {
selectedValueStream: 'selectedValueStream',
}),
isValid() {
return !this.errors?.name.length;
return !this.errors.name?.length;
},
invalidFeedback() {
return this.errors?.name.join('\n');
return this.errors.name?.join('\n');
},
hasValueStreams() {
return Boolean(this.data.length);
return Boolean(this.data.length && hasCustomValueStream(this.data));
},
selectedValueStreamName() {
return this.selectedValueStream?.name || '';
......@@ -73,10 +78,19 @@ export default {
selectedValueStreamId() {
return this.selectedValueStream?.id || null;
},
hasFormErrors() {
const { initialFormErrors } = this;
return Boolean(Object.keys(initialFormErrors).length);
},
},
watch: {
initialFormErrors(newErrors = {}) {
this.errors = newErrors;
},
},
mounted() {
const { initialFormErrors } = this;
if (Object.keys(initialFormErrors).length) {
if (this.hasFormErrors) {
this.errors = initialFormErrors;
} else {
this.onHandleInput();
......@@ -87,10 +101,12 @@ export default {
onSubmit() {
const { name } = this;
return this.createValueStream({ name }).then(() => {
this.$toast.show(sprintf(__("'%{name}' Value Stream created"), { name }), {
position: 'top-center',
});
this.name = '';
if (!this.hasFormErrors) {
this.$toast.show(sprintf(__("'%{name}' Value Stream created"), { name }), {
position: 'top-center',
});
this.name = '';
}
});
},
onHandleInput: debounce(function debouncedValidation() {
......
......@@ -76,3 +76,5 @@ export const CAPITALIZED_STAGE_NAME = Object.keys(STAGE_NAME).reduce((acc, stage
}, {});
export const PATH_HOME_ICON = 'home';
export const DEFAULT_VALUE_STREAM_ID = 'default';
......@@ -112,7 +112,6 @@ export const fetchCycleAnalyticsData = ({ dispatch }) => {
return Promise.resolve()
.then(() => dispatch('fetchValueStreams'))
.then(() => dispatch('fetchGroupStagesAndEvents'))
.then(() => dispatch('fetchStageMedianValues'))
.then(() => dispatch('receiveCycleAnalyticsDataSuccess'))
.catch(error => dispatch('receiveCycleAnalyticsDataError', error));
......@@ -144,20 +143,35 @@ export const receiveGroupStagesSuccess = ({ commit, dispatch }, stages) => {
return dispatch('setDefaultSelectedStage');
};
export const fetchValueStreamStages = ({
hasCreateMultipleValueStreams,
valueStreamId,
groupId,
params,
}) => {
return hasCreateMultipleValueStreams
? Api.cycleAnalyticsValueStreamGroupStagesAndEvents(groupId, valueStreamId, params)
: Api.cycleAnalyticsGroupStagesAndEvents(groupId, params);
};
export const fetchGroupStagesAndEvents = ({ state, dispatch, getters }) => {
const {
selectedGroup: { fullPath },
featureFlags: { hasCreateMultipleValueStreams = false },
} = state;
const {
currentValueStreamId: valueStreamId,
currentGroupPath: groupId,
cycleAnalyticsRequestParams: { created_after, project_ids },
} = getters;
dispatch('requestGroupStages');
dispatch('customStages/setStageEvents', []);
return Api.cycleAnalyticsGroupStagesAndEvents(fullPath, {
start_date: created_after,
project_ids,
return fetchValueStreamStages({
hasCreateMultipleValueStreams,
groupId,
valueStreamId,
params: { start_date: created_after, project_ids },
})
.then(({ data: { stages = [], events = [] } }) => {
dispatch('receiveGroupStagesSuccess', stages);
......@@ -307,13 +321,15 @@ export const createValueStream = ({ commit, dispatch, rootState }, data) => {
return Api.cycleAnalyticsCreateValueStream(fullPath, data)
.then(() => dispatch('receiveCreateValueStreamSuccess'))
.catch(({ response } = {}) => {
const { data: { message, errors } = null } = response;
commit(types.RECEIVE_CREATE_VALUE_STREAM_ERROR, { data, message, errors });
const { data: { message, payload: { errors } } = null } = response;
commit(types.RECEIVE_CREATE_VALUE_STREAM_ERROR, { message, errors });
});
};
export const setSelectedValueStream = ({ commit }, streamId) =>
export const setSelectedValueStream = ({ commit, dispatch }, streamId) => {
commit(types.SET_SELECTED_VALUE_STREAM, streamId);
return dispatch('fetchGroupStagesAndEvents');
};
export const receiveValueStreamsSuccess = ({ commit, dispatch }, data = []) => {
commit(types.RECEIVE_VALUE_STREAMS_SUCCESS, data);
......@@ -340,5 +356,5 @@ export const fetchValueStreams = ({ commit, dispatch, getters, state }) => {
commit(types.RECEIVE_VALUE_STREAMS_ERROR, data);
});
}
return Promise.resolve();
return dispatch('fetchGroupStagesAndEvents');
};
......@@ -65,15 +65,17 @@ export const receiveCreateStageError = (
return dispatch('setStageFormErrors', errors);
};
export const createStage = ({ dispatch, rootState }, data) => {
export const createStage = ({ dispatch, rootState, rootGetters }, data) => {
const {
selectedGroup: { fullPath },
} = rootState;
const { currentValueStreamId } = rootGetters;
dispatch('clearFormErrors');
dispatch('setSavingCustomStage');
return Api.cycleAnalyticsCreateStage(fullPath, data)
return Api.cycleAnalyticsCreateStage(fullPath, currentValueStreamId, data)
.then(response => {
const { status, data: responseData } = response;
return dispatch('receiveCreateStageSuccess', { status, data: responseData });
......
......@@ -126,7 +126,7 @@ export default {
state.isCreatingValueStream = true;
state.createValueStreamErrors = {};
},
[types.RECEIVE_CREATE_VALUE_STREAM_ERROR](state, errors = {}) {
[types.RECEIVE_CREATE_VALUE_STREAM_ERROR](state, { errors } = {}) {
state.isCreatingValueStream = false;
state.createValueStreamErrors = errors;
},
......
......@@ -144,6 +144,14 @@ export default {
return axios.get(url, { params });
},
cycleAnalyticsValueStreamGroupStagesAndEvents(groupId, valueStreamId, params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsValueStreamGroupStagesAndEventsPath)
.replace(':id', groupId)
.replace(':value_stream_id', valueStreamId);
return axios.get(url, { params });
},
cycleAnalyticsStageEvents(groupId, stageId, params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsStageEventsPath)
.replace(':id', groupId)
......@@ -160,8 +168,10 @@ export default {
return axios.get(url, { params: { ...params } });
},
cycleAnalyticsCreateStage(groupId, data) {
const url = Api.buildUrl(this.cycleAnalyticsGroupStagesAndEventsPath).replace(':id', groupId);
cycleAnalyticsCreateStage(groupId, valueStreamId, data) {
const url = Api.buildUrl(this.cycleAnalyticsValueStreamGroupStagesAndEventsPath)
.replace(':id', groupId)
.replace(':value_stream_id', valueStreamId);
return axios.post(url, data);
},
......
......@@ -126,7 +126,7 @@ module Analytics
end
def load_value_stream
if params[:value_stream_id]
if params[:value_stream_id] && params[:value_stream_id] != Analytics::CycleAnalytics::Stages::BaseService::DEFAULT_VALUE_STREAM_NAME
@value_stream = @group.value_streams.find(params[:value_stream_id])
end
end
......
......@@ -11,7 +11,7 @@ class Groups::Analytics::CycleAnalytics::ValueStreamsController < Analytics::App
end
def index
render json: Analytics::GroupValueStreamSerializer.new.represent(@group.value_streams)
render json: Analytics::GroupValueStreamSerializer.new.represent(value_streams)
end
def create
......@@ -29,4 +29,12 @@ class Groups::Analytics::CycleAnalytics::ValueStreamsController < Analytics::App
def value_stream_params
params.require(:value_stream).permit(:name)
end
def value_streams
@group.value_streams.presence || [in_memory_default_value_stream]
end
def in_memory_default_value_stream
@group.value_streams.new(name: Analytics::CycleAnalytics::Stages::BaseService::DEFAULT_VALUE_STREAM_NAME)
end
end
......@@ -4,5 +4,11 @@ module Analytics
class GroupValueStreamEntity < Grape::Entity
expose :name
expose :id
private
def id
object.id || object.name # use the name `default` if the record is not persisted
end
end
end
......@@ -54,6 +54,19 @@ RSpec.describe Analytics::CycleAnalytics::StagesController do
expect(response).to be_successful
end
context 'when `default` value_stream_id is given' do
before do
params[:value_stream_id] = Analytics::CycleAnalytics::Stages::BaseService::DEFAULT_VALUE_STREAM_NAME
end
it 'returns only the default value stream stages' do
subject
expect(response).to be_successful
expect(json_response['stages'].size).to eq(Gitlab::Analytics::CycleAnalytics::DefaultStages.all.size)
end
end
it 'renders `forbidden` based on the response of the service object' do
expect_any_instance_of(Analytics::CycleAnalytics::Stages::ListService).to receive(:can?).and_return(false)
......
......@@ -6,7 +6,6 @@ RSpec.describe Groups::Analytics::CycleAnalytics::ValueStreamsController do
let_it_be(:user) { create(:user) }
let_it_be(:group, refind: true) { create(:group) }
let(:params) { { group_id: group } }
let!(:value_stream) { create(:cycle_analytics_group_value_stream, group: group) }
before do
stub_feature_flags(Gitlab::Analytics::CYCLE_ANALYTICS_FEATURE_FLAG => true)
......@@ -17,11 +16,27 @@ RSpec.describe Groups::Analytics::CycleAnalytics::ValueStreamsController do
end
describe 'GET #index' do
it 'succeeds' do
it 'returns an in-memory default value stream' do
get :index, params: params
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('analytics/cycle_analytics/value_streams', dir: 'ee')
expect(json_response.size).to eq(1)
expect(json_response.first['id']).to eq(Analytics::CycleAnalytics::Stages::BaseService::DEFAULT_VALUE_STREAM_NAME)
expect(json_response.first['name']).to eq(Analytics::CycleAnalytics::Stages::BaseService::DEFAULT_VALUE_STREAM_NAME)
end
context 'when persisted value streams present' do
let!(:value_stream) { create(:cycle_analytics_group_value_stream, group: group) }
it 'succeeds' do
get :index, params: params
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('analytics/cycle_analytics/value_streams', dir: 'ee')
expect(json_response.first['id']).to eq(value_stream.id)
expect(json_response.first['name']).to eq(value_stream.name)
end
end
end
......
......@@ -3,7 +3,7 @@
"required": ["name", "id"],
"properties": {
"id": {
"type": "integer"
"type": ["integer", "string"]
},
"name": {
"type": "string"
......
......@@ -179,7 +179,7 @@ describe('Cycle Analytics component', () => {
expect(wrapper.find(FilterBar).exists()).toBe(flag);
};
const displaysCreateValueStream = flag => {
const displaysValueStreamSelect = flag => {
expect(wrapper.find(ValueStreamSelect).exists()).toBe(flag);
};
......@@ -247,8 +247,8 @@ describe('Cycle Analytics component', () => {
displaysPathNavigation(false);
});
it('does not display the create multiple value streams button', () => {
displaysCreateValueStream(false);
it('does not display the value stream select component', () => {
displaysValueStreamSelect(false);
});
describe('hideGroupDropDown = true', () => {
......@@ -276,8 +276,8 @@ describe('Cycle Analytics component', () => {
});
});
it('displays the create multiple value streams button', () => {
displaysCreateValueStream(true);
it('displays the value stream select component', () => {
displaysValueStreamSelect(true);
});
});
});
......
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlButton, GlModal, GlNewDropdown as GlDropdown } from '@gitlab/ui';
import { GlButton, GlModal, GlNewDropdown as GlDropdown, GlFormGroup } from '@gitlab/ui';
import ValueStreamSelect from 'ee/analytics/cycle_analytics/components/value_stream_select.vue';
import { valueStreams } from '../mock_data';
import { findDropdownItemText } from '../helpers';
......@@ -14,6 +14,7 @@ describe('ValueStreamSelect', () => {
const createValueStreamMock = jest.fn(() => Promise.resolve());
const mockEvent = { preventDefault: jest.fn() };
const mockToastShow = jest.fn();
const streamName = 'Cool stream';
const fakeStore = ({ initialState = {} }) =>
new Vuex.Store({
......@@ -52,6 +53,7 @@ describe('ValueStreamSelect', () => {
const findSelectValueStreamDropdown = () => wrapper.find(GlDropdown);
const findSelectValueStreamDropdownOptions = _wrapper => findDropdownItemText(_wrapper);
const findCreateValueStreamButton = () => wrapper.find(GlButton);
const findFormGroup = () => wrapper.find(GlFormGroup);
beforeEach(() => {
wrapper = createComponent({
......@@ -105,9 +107,30 @@ describe('ValueStreamSelect', () => {
expect(submitButtonDisabledState()).toBe(true);
});
describe('with valid fields', () => {
const streamName = 'Cool stream';
describe('form errors', () => {
const fieldErrors = ['already exists', 'is required'];
beforeEach(() => {
wrapper = createComponent({
data: { name: streamName },
initialState: {
createValueStreamErrors: {
name: fieldErrors,
},
},
});
});
it('renders the error', () => {
expect(findFormGroup().attributes('invalid-feedback')).toEqual(fieldErrors.join('\n'));
});
it('submit button is disabled', () => {
expect(submitButtonDisabledState()).toBe(true);
});
});
describe('with valid fields', () => {
beforeEach(() => {
wrapper = createComponent({ data: { name: streamName } });
});
......
......@@ -62,11 +62,10 @@ describe('Cycle analytics actions', () => {
});
it.each`
action | type | stateKey | payload
${'setFeatureFlags'} | ${'SET_FEATURE_FLAGS'} | ${'featureFlags'} | ${{ hasDurationChart: true }}
${'setSelectedProjects'} | ${'SET_SELECTED_PROJECTS'} | ${'selectedProjectIds'} | ${[10, 20, 30, 40]}
${'setSelectedStage'} | ${'SET_SELECTED_STAGE'} | ${'selectedStage'} | ${{ id: 'someStageId' }}
${'setSelectedValueStream'} | ${'SET_SELECTED_VALUE_STREAM'} | ${'selectedValueStream'} | ${{ id: 'vs-1', name: 'Value stream 1' }}
action | type | stateKey | payload
${'setFeatureFlags'} | ${'SET_FEATURE_FLAGS'} | ${'featureFlags'} | ${{ hasDurationChart: true }}
${'setSelectedProjects'} | ${'SET_SELECTED_PROJECTS'} | ${'selectedProjectIds'} | ${[10, 20, 30, 40]}
${'setSelectedStage'} | ${'SET_SELECTED_STAGE'} | ${'selectedStage'} | ${{ id: 'someStageId' }}
`('$action should set $stateKey with $payload and type $type', ({ action, type, payload }) => {
return testAction(
actions[action],
......@@ -82,6 +81,20 @@ describe('Cycle analytics actions', () => {
);
});
describe('setSelectedValueStream', () => {
const vs = { id: 'vs-1', name: 'Value stream 1' };
it('dispatches the fetchCycleAnalyticsData action', () => {
return testAction(
actions.setSelectedValueStream,
vs,
{ ...state, selectedValueStream: {} },
[{ type: types.SET_SELECTED_VALUE_STREAM, payload: vs }],
[{ type: 'fetchGroupStagesAndEvents' }],
);
});
});
describe('setDateRange', () => {
const payload = { startDate, endDate };
......@@ -256,7 +269,6 @@ describe('Cycle analytics actions', () => {
[
{ type: 'requestCycleAnalyticsData' },
{ type: 'fetchValueStreams' },
{ type: 'fetchGroupStagesAndEvents' },
{ type: 'fetchStageMedianValues' },
{ type: 'receiveCycleAnalyticsDataSuccess' },
],
......@@ -910,7 +922,9 @@ describe('Cycle analytics actions', () => {
});
describe('with errors', () => {
const resp = { message: 'error', errors: {} };
const errors = { name: ['is taken'] };
const message = { message: 'error' };
const resp = { message, payload: { errors } };
beforeEach(() => {
mock.onPost(endpoints.valueStreamData).replyOnce(httpStatusCodes.NOT_FOUND, resp);
});
......@@ -924,7 +938,7 @@ describe('Cycle analytics actions', () => {
{ type: types.REQUEST_CREATE_VALUE_STREAM },
{
type: types.RECEIVE_CREATE_VALUE_STREAM_ERROR,
payload: { data: { ...payload }, ...resp },
payload: { message, errors },
},
],
[],
......@@ -1016,8 +1030,14 @@ describe('Cycle analytics actions', () => {
};
});
it(`will skip making a request`, () =>
testAction(actions.fetchValueStreams, null, state, [], []));
it(`will dispatch the 'fetchGroupStagesAndEvents' request`, () =>
testAction(
actions.fetchValueStreams,
null,
state,
[],
[{ type: 'fetchGroupStagesAndEvents' }],
));
});
});
});
......@@ -57,15 +57,15 @@ describe('Cycle analytics mutations', () => {
});
it.each`
mutation | payload | expectedState
${types.SET_FEATURE_FLAGS} | ${{ hasDurationChart: true }} | ${{ featureFlags: { hasDurationChart: true } }}
${types.SET_SELECTED_GROUP} | ${{ fullPath: 'cool-beans' }} | ${{ selectedGroup: { fullPath: 'cool-beans' }, selectedProjects: [] }}
${types.SET_SELECTED_PROJECTS} | ${selectedProjects} | ${{ selectedProjects }}
${types.SET_DATE_RANGE} | ${{ startDate, endDate }} | ${{ startDate, endDate }}
${types.SET_SELECTED_STAGE} | ${{ id: 'first-stage' }} | ${{ selectedStage: { id: 'first-stage' } }}
${types.RECEIVE_CREATE_VALUE_STREAM_ERROR} | ${{ name: ['is required'] }} | ${{ createValueStreamErrors: { name: ['is required'] }, isCreatingValueStream: false }}
${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${valueStreams} | ${{ valueStreams, isLoadingValueStreams: false }}
${types.SET_SELECTED_VALUE_STREAM} | ${valueStreams[1].id} | ${{ selectedValueStream: {} }}
mutation | payload | expectedState
${types.SET_FEATURE_FLAGS} | ${{ hasDurationChart: true }} | ${{ featureFlags: { hasDurationChart: true } }}
${types.SET_SELECTED_GROUP} | ${{ fullPath: 'cool-beans' }} | ${{ selectedGroup: { fullPath: 'cool-beans' }, selectedProjects: [] }}
${types.SET_SELECTED_PROJECTS} | ${selectedProjects} | ${{ selectedProjects }}
${types.SET_DATE_RANGE} | ${{ startDate, endDate }} | ${{ startDate, endDate }}
${types.SET_SELECTED_STAGE} | ${{ id: 'first-stage' }} | ${{ selectedStage: { id: 'first-stage' } }}
${types.RECEIVE_CREATE_VALUE_STREAM_ERROR} | ${{ errors: { name: ['is required'] } }} | ${{ createValueStreamErrors: { name: ['is required'] }, isCreatingValueStream: false }}
${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${valueStreams} | ${{ valueStreams, isLoadingValueStreams: false }}
${types.SET_SELECTED_VALUE_STREAM} | ${valueStreams[1].id} | ${{ selectedValueStream: {} }}
`(
'$mutation with payload $payload will update state with $expectedState',
({ mutation, payload, expectedState }) => {
......
......@@ -442,6 +442,8 @@ describe('Api', () => {
});
describe('cycleAnalyticsCreateStage', () => {
const valueStreamId = 'fake-value-stream';
it('submit the custom stage data', done => {
const response = {};
const customStage = {
......@@ -451,10 +453,10 @@ describe('Api', () => {
end_event_identifier: 'issue_closed',
end_event_label_id: null,
};
const expectedUrl = `${dummyCycleAnalyticsUrlRoot}/-/analytics/value_stream_analytics/stages`;
const expectedUrl = `${dummyCycleAnalyticsUrlRoot}/-/analytics/value_stream_analytics/value_streams/${valueStreamId}/stages`;
mock.onPost(expectedUrl).reply(httpStatus.OK, response);
Api.cycleAnalyticsCreateStage(groupId, customStage)
Api.cycleAnalyticsCreateStage(groupId, valueStreamId, customStage)
.then(({ data, config: { data: reqData, url } }) => {
expect(data).toEqual(response);
expect(JSON.parse(reqData)).toMatchObject(customStage);
......
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