Commit 7260ed2b authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Add action specs

Adds related vuex specs to cover the
actions, mutations and getters

Added mutation specs

Added loading state key for the value
streams api request

Clean up specs
parent 625d2a1d
......@@ -79,6 +79,7 @@ export default {
'startDate',
'endDate',
'medians',
'isLoadingValueStreams',
]),
// NOTE: formEvents are fetched in the same request as the list of stages (fetchGroupStagesAndEvents)
// so i think its ok to bind formEvents here even though its only used as a prop to the custom-stage-form
......@@ -114,7 +115,9 @@ export default {
return this.featureFlags.hasFilterBar && this.currentGroupPath;
},
shouldDisplayCreateMultipleValueStreams() {
return Boolean(this.featureFlags.hasCreateMultipleValueStreams);
return Boolean(
this.featureFlags.hasCreateMultipleValueStreams && !this.isLoadingValueStreams,
);
},
isLoadingTypeOfWork() {
return this.isLoadingTasksByTypeChartTopLabels || this.isLoadingTasksByTypeChart;
......
......@@ -108,7 +108,12 @@ export default {
</script>
<template>
<gl-form>
<gl-dropdown v-if="hasValueStreams" :text="selectedValueStreamName" right>
<gl-dropdown
v-if="hasValueStreams"
data-testid="select-value-stream"
:text="selectedValueStreamName"
right
>
<gl-dropdown-item
v-for="{ id, name: streamName } in data"
:key="id"
......@@ -122,9 +127,13 @@ export default {
__('Create new value stream')
}}</gl-dropdown-item>
</gl-dropdown>
<gl-button v-else v-gl-modal-directive="'create-value-stream-modal'" @click="onHandleInput">{{
__('Create new value stream')
}}</gl-button>
<gl-button
v-else
v-gl-modal-directive="'create-value-stream-modal'"
data-testid="create-value-stream"
@click="onHandleInput"
>{{ __('Create new value stream') }}</gl-button
>
<gl-modal
ref="modal"
modal-id="create-value-stream-modal"
......
......@@ -109,10 +109,6 @@ export const fetchCycleAnalyticsData = ({ dispatch }) => {
removeFlash();
dispatch('requestCycleAnalyticsData');
// TODO: we will need to do some extra refactoring here
// We need the selected value stream to be selected first
// once we have that, we can then fetch the groups stages and events,
// this is because they will now live under the /value_streams entity
return Promise.resolve()
.then(() => dispatch('fetchValueStreams'))
......@@ -302,7 +298,12 @@ export const reorderStage = ({ dispatch, state }, initialData) => {
);
};
export const createValueStream = ({ commit, rootState }, data) => {
export const receiveCreateValueStreamSuccess = ({ commit, dispatch }) => {
commit(types.RECEIVE_CREATE_VALUE_STREAM_SUCCESS);
return dispatch('fetchValueStreams');
};
export const createValueStream = ({ commit, dispatch, rootState }, data) => {
const {
selectedGroup: { fullPath },
} = rootState;
......@@ -310,10 +311,7 @@ export const createValueStream = ({ commit, rootState }, data) => {
commit(types.REQUEST_CREATE_VALUE_STREAM);
return Api.cycleAnalyticsCreateValueStream(fullPath, data)
.then(response => {
const { status, data: responseData } = response;
commit(types.RECEIVE_CREATE_VALUE_STREAM_SUCCESS, { status, data: responseData });
})
.then(() => dispatch('receiveCreateValueStreamSuccess'))
.catch(({ response } = {}) => {
const { data: { message, errors } = null } = response;
commit(types.RECEIVE_CREATE_VALUE_STREAM_ERROR, { data, message, errors });
......@@ -323,13 +321,16 @@ export const createValueStream = ({ commit, rootState }, data) => {
export const setSelectedValueStream = ({ commit }, streamId) =>
commit(types.SET_SELECTED_VALUE_STREAM, streamId);
export const receiveValueStreams = ({ commit, dispatch }, { data }) => {
export const receiveValueStreamsSuccess = ({ commit, dispatch }, data = []) => {
commit(types.RECEIVE_VALUE_STREAMS_SUCCESS, data);
const [firstStream] = data;
return dispatch('setSelectedValueStream', firstStream.id);
if (data.length) {
const [firstStream] = data;
return dispatch('setSelectedValueStream', firstStream.id);
}
return Promise.resolve();
};
export const fetchValueStreams = ({ commit, dispatch, getters, state }, data) => {
export const fetchValueStreams = ({ commit, dispatch, getters, state }) => {
const {
featureFlags: { hasCreateMultipleValueStreams = false },
} = state;
......@@ -339,13 +340,10 @@ export const fetchValueStreams = ({ commit, dispatch, getters, state }, data) =>
commit(types.REQUEST_VALUE_STREAMS);
return Api.cycleAnalyticsValueStreams(currentGroupPath)
.then(response => {
const { status, data: responseData } = response;
return dispatch('receiveValueStreams', { status, data: responseData });
})
.catch(({ response } = {}) => {
const { data: { message, errors } = null } = response;
commit(types.RECEIVE_VALUE_STREAMS_ERROR, { data, message, errors });
.then(({ data }) => dispatch('receiveValueStreamsSuccess', data))
.catch(response => {
const { data } = response;
commit(types.RECEIVE_VALUE_STREAMS_ERROR, data);
});
}
return Promise.resolve();
......
......@@ -135,15 +135,18 @@ export default {
state.createValueStreamErrors = {};
},
[types.SET_SELECTED_VALUE_STREAM](state, streamId) {
state.selectedValueStream = state.valueStreams.find(({ id }) => id === streamId);
state.selectedValueStream = state.valueStreams?.find(({ id }) => id === streamId) || null;
},
[types.REQUEST_VALUE_STREAMS](state) {
state.isLoadingValueStreams = true;
state.valueStreams = [];
},
[types.RECEIVE_VALUE_STREAMS_ERROR](state) {
state.isLoadingValueStreams = false;
state.valueStreams = [];
},
[types.RECEIVE_VALUE_STREAMS_SUCCESS](state, data) {
state.isLoadingValueStreams = false;
state.valueStreams = data;
},
};
......@@ -24,6 +24,7 @@ export default () => ({
currentStageEvents: [],
isLoadingValueStreams: false,
isCreatingValueStream: false,
createValueStreamErrors: {},
......
......@@ -143,14 +143,6 @@ 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)
......
......@@ -4,6 +4,8 @@ module Analytics
module CycleAnalytics
module Stages
class ListService < BaseService
extend ::Gitlab::Utils::Override
def execute
return forbidden unless can?(current_user, :read_group_cycle_analytics, parent)
......@@ -21,6 +23,11 @@ module Analytics
scope = scope.by_value_stream(params[:value_stream]) if params[:value_stream]
scope.for_list
end
override :value_stream
def value_stream
@value_stream ||= (params[:value_stream] || parent.value_streams.new(name: DEFAULT_VALUE_STREAM_NAME))
end
end
end
end
......
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlModal } from '@gitlab/ui';
import store from 'ee/analytics/cycle_analytics/store';
import ValueStreamSelect from 'ee/analytics/cycle_analytics/components/value_stream_select.vue';
import { valueStreams } from '../mock_data';
import { findDropdownItemText } from '../helpers';
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -15,10 +16,21 @@ describe('ValueStreamSelect', () => {
const mockModalHide = jest.fn();
const mockToastShow = jest.fn();
const createComponent = ({ data = {}, methods = {} } = {}) =>
const fakeStore = ({ initialState = {} }) =>
new Vuex.Store({
state: {
isLoading: false,
createValueStreamErrors: {},
valueStreams: [],
selectedValueStream: {},
...initialState,
},
});
const createComponent = ({ data = {}, initialState = {}, methods = {} } = {}) =>
shallowMount(ValueStreamSelect, {
localVue,
store,
store: fakeStore({ initialState }),
data() {
return {
...data,
......@@ -38,15 +50,56 @@ describe('ValueStreamSelect', () => {
const findModal = () => wrapper.find(GlModal);
const submitButtonDisabledState = () => findModal().props('actionPrimary').attributes[1].disabled;
const submitForm = () => findModal().vm.$emit('primary', mockEvent);
const findSelectValueStreamDropdown = () => wrapper.find('[data-testid="select-value-stream"]');
const findSelectValueStreamDropdownOptions = _wrapper => findDropdownItemText(_wrapper);
const findCreateValueStreamButton = () => wrapper.find('[data-testid="create-value-stream"]');
beforeEach(() => {
wrapper = createComponent();
wrapper = createComponent({
initialState: {
valueStreams,
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('does not display the create value stream button', () => {
expect(findCreateValueStreamButton().exists()).toBe(false);
});
it('displays the select value stream dropdown', () => {
expect(findSelectValueStreamDropdown().exists()).toBe(true);
});
it('renders each value stream including a create button', () => {
const opts = findSelectValueStreamDropdownOptions(wrapper);
[...valueStreams.map(v => v.name), 'Create new value stream'].forEach(vs => {
expect(opts).toContain(vs);
});
});
describe('No value streams available', () => {
beforeEach(() => {
wrapper = createComponent({
initialState: {
valueStreams: [],
},
});
});
it('displays the create value stream button', () => {
expect(findCreateValueStreamButton().exists()).toBe(true);
});
it('does not display the select value stream dropdown', () => {
expect(findSelectValueStreamDropdown().exists()).toBe(false);
});
});
describe('Create value stream form', () => {
it('submit button is disabled', () => {
expect(submitButtonDisabledState()).toBe(true);
......@@ -68,6 +121,7 @@ describe('ValueStreamSelect', () => {
beforeEach(() => {
submitForm();
});
it('calls the "createValueStream" event when submitted', () => {
expect(createValueStreamMock).toHaveBeenCalledWith({ name: streamName });
});
......@@ -93,8 +147,8 @@ describe('ValueStreamSelect', () => {
beforeEach(() => {
wrapper = createComponent({
data: { name: streamName },
methods: {
createValueStream: createValueStreamMockFail,
actions: {
createValueStream: () => createValueStreamMockFail,
},
});
wrapper.vm.$refs.modal.hide = mockModalHide;
......
import { GlNewDropdownItem as GlDropdownItem } from '@gitlab/ui';
export function renderTotalTime(selector, element, totalTime = {}) {
const { days, hours, mins, seconds } = totalTime;
if (days) {
......@@ -17,7 +19,14 @@ export function renderTotalTime(selector, element, totalTime = {}) {
export const shouldFlashAMessage = (msg = '') =>
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(msg);
export const findDropdownItems = wrapper => wrapper.findAll(GlDropdownItem);
export const findDropdownItemText = wrapper =>
findDropdownItems(wrapper).wrappers.map(w => w.text());
export default {
renderTotalTime,
shouldFlashAMessage,
findDropdownItems,
findDropdownItemText,
};
......@@ -38,6 +38,8 @@ export const endpoints = {
valueStreamData: /analytics\/value_stream_analytics\/value_streams/,
};
export const valueStreams = [{ id: 1, name: 'Value stream 1' }, { id: 2, name: 'Value stream 2' }];
export const groupLabels = getJSONFixture(fixtureEndpoints.groupLabels).map(
convertObjectPropsToCamelCase,
);
......
......@@ -13,6 +13,7 @@ import {
endDate,
customizableStagesAndEvents,
endpoints,
valueStreams,
} from '../mock_data';
const stageData = { events: [] };
......@@ -54,10 +55,11 @@ 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' }}
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 should set $stateKey with $payload and type $type', ({ action, type, payload }) => {
return testAction(
actions[action],
......@@ -876,13 +878,11 @@ describe('Cycle analytics actions', () => {
payload,
state,
[
{ type: types.REQUEST_CREATE_VALUE_STREAM },
{
type: types.RECEIVE_CREATE_VALUE_STREAM_SUCCESS,
payload: { status: httpStatusCodes.OK, data: {} },
type: types.REQUEST_CREATE_VALUE_STREAM,
},
],
[],
[{ type: 'receiveCreateValueStreamSuccess' }],
);
});
});
......@@ -910,4 +910,92 @@ describe('Cycle analytics actions', () => {
});
});
});
describe('fetchValueStreams', () => {
beforeEach(() => {
state = {
...state,
stages: [{ slug: selectedStageSlug }],
selectedGroup,
featureFlags: {
...state.featureFlags,
hasCreateMultipleValueStreams: true,
},
};
mock = new MockAdapter(axios);
mock.onGet(endpoints.valueStreamData).reply(200, { stages: [], events: [] });
});
it(`commits ${types.REQUEST_VALUE_STREAMS} and dispatches receiveValueStreamsSuccess with received data on success`, () => {
return testAction(
actions.fetchValueStreams,
null,
state,
[{ type: types.REQUEST_VALUE_STREAMS }],
[
{
payload: {
events: [],
stages: [],
},
type: 'receiveValueStreamsSuccess',
},
],
);
});
describe('with a failing request', () => {
const resp = { data: {} };
beforeEach(() => {
mock.onGet(endpoints.valueStreamData).reply(httpStatusCodes.NOT_FOUND, resp);
});
it(`will commit ${types.RECEIVE_VALUE_STREAMS_ERROR}`, () => {
return testAction(
actions.fetchValueStreams,
null,
state,
[
{ type: types.REQUEST_VALUE_STREAMS },
{
type: types.RECEIVE_VALUE_STREAMS_ERROR,
},
],
[],
);
});
});
describe('receiveValueStreamsSuccess', () => {
it(`commits the ${types.RECEIVE_VALUE_STREAMS_SUCCESS} mutation`, () => {
return testAction(
actions.receiveValueStreamsSuccess,
valueStreams,
state,
[
{
type: types.RECEIVE_VALUE_STREAMS_SUCCESS,
payload: valueStreams,
},
],
[{ type: 'setSelectedValueStream', payload: 1 }],
);
});
});
describe('with hasCreateMultipleValueStreams disabled', () => {
beforeEach(() => {
state = {
...state,
featureFlags: {
...state.featureFlags,
hasCreateMultipleValueStreams: false,
},
};
});
it(`will skip making a request`, () =>
testAction(actions.fetchValueStreams, null, state, [], []));
});
});
});
......@@ -12,6 +12,7 @@ import {
endDate,
selectedProjects,
customizableStagesAndEvents,
valueStreams,
} from '../mock_data';
let state = null;
......@@ -27,6 +28,10 @@ describe('Cycle analytics mutations', () => {
it.each`
mutation | stateKey | value
${types.REQUEST_VALUE_STREAMS} | ${'valueStreams'} | ${[]}
${types.RECEIVE_VALUE_STREAMS_ERROR} | ${'valueStreams'} | ${[]}
${types.REQUEST_VALUE_STREAMS} | ${'isLoadingValueStreams'} | ${true}
${types.RECEIVE_VALUE_STREAMS_ERROR} | ${'isLoadingValueStreams'} | ${false}
${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false}
......@@ -58,11 +63,14 @@ describe('Cycle analytics mutations', () => {
${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.SET_SELECTED_VALUE_STREAM} | ${valueStreams[1].id} | ${{ selectedValueStream: valueStreams[1] }}
${types.RECEIVE_CREATE_VALUE_STREAM_ERROR} | ${{ name: ['is required'] }} | ${{ createValueStreamErrors: { name: ['is required'] }, isCreatingValueStream: false }}
${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${valueStreams} | ${{ valueStreams, isLoadingValueStreams: false }}
`(
'$mutation with payload $payload will update state with $expectedState',
({ mutation, payload, expectedState }) => {
state = {
valueStreams,
selectedGroup: { fullPath: 'rad-stage' },
};
mutations[mutation](state, payload);
......
......@@ -334,6 +334,45 @@ describe('Api', () => {
});
});
describe('cycleAnalyticsValueStreams', () => {
it('fetches custom value streams', done => {
const response = [{ name: 'value stream 1', id: 1 }];
const expectedUrl = `${dummyCycleAnalyticsUrlRoot}/-/analytics/value_stream_analytics/value_streams`;
mock.onGet(expectedUrl).reply(200, response);
Api.cycleAnalyticsValueStreams(groupId)
.then(responseObj =>
expectRequestWithCorrectParameters(responseObj, {
response,
expectedUrl,
}),
)
.then(done)
.catch(done.fail);
});
});
describe('cycleAnalyticsCreateValueStream', () => {
it('submit the custom value stream data', done => {
const response = {};
const customValueStream = {
name: 'cool-value-stream-stage',
};
const expectedUrl = `${dummyCycleAnalyticsUrlRoot}/-/analytics/value_stream_analytics/value_streams`;
mock.onPost(expectedUrl).reply(200, response);
Api.cycleAnalyticsCreateValueStream(groupId, customValueStream)
.then(({ data, config: { data: reqData, url } }) => {
expect(data).toEqual(response);
expect(JSON.parse(reqData)).toMatchObject(customValueStream);
expect(url).toEqual(expectedUrl);
})
.then(done)
.catch(done.fail);
});
});
describe('cycleAnalyticsGroupStagesAndEvents', () => {
it('fetches custom stage events and all stages', done => {
const response = { events: [], stages: [] };
......
......@@ -28,6 +28,10 @@ RSpec.describe Analytics::CycleAnalytics::Stages::ListService do
expect(stages.map(&:id)).to all(be_nil)
end
it 'does not persist the value stream record' do
expect { subject }.not_to change { Analytics::CycleAnalytics::GroupValueStream.count }
end
context 'when there are persisted stages' do
let_it_be(:stage1) { create(:cycle_analytics_group_stage, parent: group, relative_position: 2, value_stream: value_stream) }
let_it_be(:stage2) { create(:cycle_analytics_group_stage, parent: group, relative_position: 3, value_stream: value_stream) }
......
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