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

Merge branch '221204-update-stage-endpoints-for-value-stream' into 'master'

Update stages endpoints to include value stream id

See merge request gitlab-org/gitlab!37047
parents 665a5d43 79a8cb9c
......@@ -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