Commit 84f4d0e8 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '221204-fe-multiple-value-streams-switch-between-value-streams-mvc' into 'master'

Switch between value streams MVC

See merge request gitlab-org/gitlab!35729
parents 59c288db 980bb468
...@@ -79,6 +79,7 @@ export default { ...@@ -79,6 +79,7 @@ export default {
'startDate', 'startDate',
'endDate', 'endDate',
'medians', 'medians',
'isLoadingValueStreams',
]), ]),
// NOTE: formEvents are fetched in the same request as the list of stages (fetchGroupStagesAndEvents) // 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 // 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 { ...@@ -114,7 +115,9 @@ export default {
return this.featureFlags.hasFilterBar && this.currentGroupPath; return this.featureFlags.hasFilterBar && this.currentGroupPath;
}, },
shouldDisplayCreateMultipleValueStreams() { shouldDisplayCreateMultipleValueStreams() {
return Boolean(this.featureFlags.hasCreateMultipleValueStreams); return Boolean(
this.featureFlags.hasCreateMultipleValueStreams && !this.isLoadingValueStreams,
);
}, },
isLoadingTypeOfWork() { isLoadingTypeOfWork() {
return this.isLoadingTasksByTypeChartTopLabels || this.isLoadingTasksByTypeChart; return this.isLoadingTasksByTypeChartTopLabels || this.isLoadingTasksByTypeChart;
......
<script> <script>
import { GlButton, GlForm, GlFormInput, GlFormGroup, GlModal, GlModalDirective } from '@gitlab/ui'; import {
GlButton,
GlNewDropdown as GlDropdown,
GlNewDropdownItem as GlDropdownItem,
GlNewDropdownDivider as GlDropdownDivider,
GlForm,
GlFormInput,
GlFormGroup,
GlModal,
GlModalDirective,
} from '@gitlab/ui';
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { sprintf, __ } from '~/locale'; import { sprintf, __ } from '~/locale';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { DATA_REFETCH_DELAY } from '../../shared/constants';
const ERRORS = { const ERRORS = {
MIN_LENGTH: __('Name is required'), MIN_LENGTH: __('Name is required'),
...@@ -23,6 +34,9 @@ const validate = ({ name }) => { ...@@ -23,6 +34,9 @@ const validate = ({ name }) => {
export default { export default {
components: { components: {
GlButton, GlButton,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlForm, GlForm,
GlFormInput, GlFormInput,
GlFormGroup, GlFormGroup,
...@@ -41,6 +55,8 @@ export default { ...@@ -41,6 +55,8 @@ export default {
...mapState({ ...mapState({
isLoading: 'isCreatingValueStream', isLoading: 'isCreatingValueStream',
initialFormErrors: 'createValueStreamErrors', initialFormErrors: 'createValueStreamErrors',
data: 'valueStreams',
selectedValueStream: 'selectedValueStream',
}), }),
isValid() { isValid() {
return !this.errors?.name.length; return !this.errors?.name.length;
...@@ -48,6 +64,15 @@ export default { ...@@ -48,6 +64,15 @@ export default {
invalidFeedback() { invalidFeedback() {
return this.errors?.name.join('\n'); return this.errors?.name.join('\n');
}, },
hasValueStreams() {
return Boolean(this.data.length);
},
selectedValueStreamName() {
return this.selectedValueStream?.name || '';
},
selectedValueStreamId() {
return this.selectedValueStream?.id || null;
},
}, },
mounted() { mounted() {
const { initialFormErrors } = this; const { initialFormErrors } = this;
...@@ -58,11 +83,10 @@ export default { ...@@ -58,11 +83,10 @@ export default {
} }
}, },
methods: { methods: {
...mapActions(['createValueStream']), ...mapActions(['createValueStream', 'setSelectedValueStream']),
onSubmit() { onSubmit() {
const { name } = this; const { name } = this;
return this.createValueStream({ name }).then(() => { return this.createValueStream({ name }).then(() => {
this.$refs.modal.hide();
this.$toast.show(sprintf(__("'%{name}' Value Stream created"), { name }), { this.$toast.show(sprintf(__("'%{name}' Value Stream created"), { name }), {
position: 'top-center', position: 'top-center',
}); });
...@@ -72,23 +96,43 @@ export default { ...@@ -72,23 +96,43 @@ export default {
onHandleInput: debounce(function debouncedValidation() { onHandleInput: debounce(function debouncedValidation() {
const { name } = this; const { name } = this;
this.errors = validate({ name }); this.errors = validate({ name });
}, 250), }, DATA_REFETCH_DELAY),
isSelected(id) {
return Boolean(this.selectedValueStreamId && this.selectedValueStreamId === id);
},
onSelect(id) {
this.setSelectedValueStream(id);
},
}, },
}; };
</script> </script>
<template> <template>
<gl-form> <gl-form>
<gl-button v-gl-modal-directive="'create-value-stream-modal'" @click="onHandleInput">{{ <gl-dropdown v-if="hasValueStreams" :text="selectedValueStreamName" right>
__('Create new value stream') <gl-dropdown-item
v-for="{ id, name: streamName } in data"
:key="id"
:is-check-item="true"
:is-checked="isSelected(id)"
@click="onSelect(id)"
>{{ streamName }}</gl-dropdown-item
>
<gl-dropdown-divider />
<gl-dropdown-item v-gl-modal-directive="'create-value-stream-modal'" @click="onHandleInput">{{
__('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>
<gl-modal <gl-modal
ref="modal" ref="modal"
modal-id="create-value-stream-modal" modal-id="create-value-stream-modal"
:title="__('Value Stream Name')" :title="__('Value Stream Name')"
:action-primary="{ :action-primary="{
text: __('Create value stream'), text: __('Create Value Stream'),
attributes: [ attributes: [
{ variant: 'primary' }, { variant: 'success' },
{ {
disabled: !isValid, disabled: !isValid,
}, },
...@@ -108,7 +152,7 @@ export default { ...@@ -108,7 +152,7 @@ export default {
id="create-value-stream-name" id="create-value-stream-name"
v-model.trim="name" v-model.trim="name"
name="create-value-stream-name" name="create-value-stream-name"
:placeholder="__('Example: My value stream')" :placeholder="__('Example: My Value Stream')"
:state="isValid" :state="isValid"
required required
@input="onHandleInput" @input="onHandleInput"
......
...@@ -109,7 +109,9 @@ export const fetchCycleAnalyticsData = ({ dispatch }) => { ...@@ -109,7 +109,9 @@ export const fetchCycleAnalyticsData = ({ dispatch }) => {
removeFlash(); removeFlash();
dispatch('requestCycleAnalyticsData'); dispatch('requestCycleAnalyticsData');
return Promise.resolve() return Promise.resolve()
.then(() => dispatch('fetchValueStreams'))
.then(() => dispatch('fetchGroupStagesAndEvents')) .then(() => dispatch('fetchGroupStagesAndEvents'))
.then(() => dispatch('fetchStageMedianValues')) .then(() => dispatch('fetchStageMedianValues'))
.then(() => dispatch('receiveCycleAnalyticsDataSuccess')) .then(() => dispatch('receiveCycleAnalyticsDataSuccess'))
...@@ -296,7 +298,12 @@ export const reorderStage = ({ dispatch, state }, initialData) => { ...@@ -296,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 { const {
selectedGroup: { fullPath }, selectedGroup: { fullPath },
} = rootState; } = rootState;
...@@ -304,12 +311,40 @@ export const createValueStream = ({ commit, rootState }, data) => { ...@@ -304,12 +311,40 @@ export const createValueStream = ({ commit, rootState }, data) => {
commit(types.REQUEST_CREATE_VALUE_STREAM); commit(types.REQUEST_CREATE_VALUE_STREAM);
return Api.cycleAnalyticsCreateValueStream(fullPath, data) return Api.cycleAnalyticsCreateValueStream(fullPath, data)
.then(response => { .then(() => dispatch('receiveCreateValueStreamSuccess'))
const { status, data: responseData } = response;
commit(types.RECEIVE_CREATE_VALUE_STREAM_SUCCESS, { status, data: responseData });
})
.catch(({ response } = {}) => { .catch(({ response } = {}) => {
const { data: { message, errors } = null } = response; const { data: { message, errors } = null } = response;
commit(types.RECEIVE_CREATE_VALUE_STREAM_ERROR, { data, message, errors }); commit(types.RECEIVE_CREATE_VALUE_STREAM_ERROR, { data, message, errors });
}); });
}; };
export const setSelectedValueStream = ({ commit }, streamId) =>
commit(types.SET_SELECTED_VALUE_STREAM, streamId);
export const receiveValueStreamsSuccess = ({ commit, dispatch }, data = []) => {
commit(types.RECEIVE_VALUE_STREAMS_SUCCESS, data);
if (data.length) {
const [firstStream] = data;
return dispatch('setSelectedValueStream', firstStream.id);
}
return Promise.resolve();
};
export const fetchValueStreams = ({ commit, dispatch, getters, state }) => {
const {
featureFlags: { hasCreateMultipleValueStreams = false },
} = state;
const { currentGroupPath } = getters;
if (hasCreateMultipleValueStreams) {
commit(types.REQUEST_VALUE_STREAMS);
return Api.cycleAnalyticsValueStreams(currentGroupPath)
.then(({ data }) => dispatch('receiveValueStreamsSuccess', data))
.catch(response => {
const { data } = response;
commit(types.RECEIVE_VALUE_STREAMS_ERROR, data);
});
}
return Promise.resolve();
};
...@@ -6,6 +6,8 @@ import { transformStagesForPathNavigation } from '../utils'; ...@@ -6,6 +6,8 @@ import { transformStagesForPathNavigation } from '../utils';
export const hasNoAccessError = state => state.errorCode === httpStatus.FORBIDDEN; export const hasNoAccessError = state => state.errorCode === httpStatus.FORBIDDEN;
export const currentValueStreamId = ({ selectedValueStream }) => selectedValueStream?.id || null;
export const currentGroupPath = ({ selectedGroup }) => export const currentGroupPath = ({ selectedGroup }) =>
selectedGroup && selectedGroup.fullPath ? selectedGroup.fullPath : null; selectedGroup && selectedGroup.fullPath ? selectedGroup.fullPath : null;
......
...@@ -5,6 +5,7 @@ export const SET_SELECTED_PROJECTS = 'SET_SELECTED_PROJECTS'; ...@@ -5,6 +5,7 @@ export const SET_SELECTED_PROJECTS = 'SET_SELECTED_PROJECTS';
export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE'; export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE';
export const SET_DATE_RANGE = 'SET_DATE_RANGE'; export const SET_DATE_RANGE = 'SET_DATE_RANGE';
export const SET_SELECTED_FILTERS = 'SET_SELECTED_FILTERS'; export const SET_SELECTED_FILTERS = 'SET_SELECTED_FILTERS';
export const SET_SELECTED_VALUE_STREAM = 'SET_SELECTED_VALUE_STREAM';
export const REQUEST_CYCLE_ANALYTICS_DATA = 'REQUEST_CYCLE_ANALYTICS_DATA'; export const REQUEST_CYCLE_ANALYTICS_DATA = 'REQUEST_CYCLE_ANALYTICS_DATA';
export const RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS = 'RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS'; export const RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS = 'RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS';
...@@ -39,3 +40,7 @@ export const RECEIVE_REORDER_STAGE_ERROR = 'RECEIVE_REORDER_STAGE_ERROR'; ...@@ -39,3 +40,7 @@ export const RECEIVE_REORDER_STAGE_ERROR = 'RECEIVE_REORDER_STAGE_ERROR';
export const REQUEST_CREATE_VALUE_STREAM = 'REQUEST_CREATE_VALUE_STREAM'; export const REQUEST_CREATE_VALUE_STREAM = 'REQUEST_CREATE_VALUE_STREAM';
export const RECEIVE_CREATE_VALUE_STREAM_SUCCESS = 'RECEIVE_CREATE_VALUE_STREAM_SUCCESS'; export const RECEIVE_CREATE_VALUE_STREAM_SUCCESS = 'RECEIVE_CREATE_VALUE_STREAM_SUCCESS';
export const RECEIVE_CREATE_VALUE_STREAM_ERROR = 'RECEIVE_CREATE_VALUE_STREAM_ERROR'; export const RECEIVE_CREATE_VALUE_STREAM_ERROR = 'RECEIVE_CREATE_VALUE_STREAM_ERROR';
export const REQUEST_VALUE_STREAMS = 'REQUEST_VALUE_STREAMS';
export const RECEIVE_VALUE_STREAMS_SUCCESS = 'RECEIVE_VALUE_STREAMS_SUCCESS';
export const RECEIVE_VALUE_STREAMS_ERROR = 'RECEIVE_VALUE_STREAMS_ERROR';
...@@ -134,4 +134,19 @@ export default { ...@@ -134,4 +134,19 @@ export default {
state.isCreatingValueStream = false; state.isCreatingValueStream = false;
state.createValueStreamErrors = {}; state.createValueStreamErrors = {};
}, },
[types.SET_SELECTED_VALUE_STREAM](state, 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;
},
}; };
...@@ -20,13 +20,16 @@ export default () => ({ ...@@ -20,13 +20,16 @@ export default () => ({
selectedMilestone: null, selectedMilestone: null,
selectedAssignees: [], selectedAssignees: [],
selectedLabels: [], selectedLabels: [],
selectedValueStream: null,
currentStageEvents: [], currentStageEvents: [],
isLoadingValueStreams: false,
isCreatingValueStream: false, isCreatingValueStream: false,
createValueStreamErrors: {}, createValueStreamErrors: {},
stages: [], stages: [],
summary: [], summary: [],
medians: {}, medians: {},
valueStreams: [],
}); });
...@@ -15,6 +15,8 @@ export default { ...@@ -15,6 +15,8 @@ export default {
cycleAnalyticsSummaryDataPath: '/groups/:id/-/analytics/value_stream_analytics/summary', cycleAnalyticsSummaryDataPath: '/groups/:id/-/analytics/value_stream_analytics/summary',
cycleAnalyticsTimeSummaryDataPath: '/groups/:id/-/analytics/value_stream_analytics/time_summary', cycleAnalyticsTimeSummaryDataPath: '/groups/:id/-/analytics/value_stream_analytics/time_summary',
cycleAnalyticsGroupStagesAndEventsPath: '/groups/:id/-/analytics/value_stream_analytics/stages', cycleAnalyticsGroupStagesAndEventsPath: '/groups/:id/-/analytics/value_stream_analytics/stages',
cycleAnalyticsValueStreamGroupStagesAndEventsPath:
'/groups/:id/-/analytics/value_stream_analytics/value_streams/:value_stream_id/stages',
cycleAnalyticsValueStreamsPath: '/groups/:id/-/analytics/value_stream_analytics/value_streams', cycleAnalyticsValueStreamsPath: '/groups/:id/-/analytics/value_stream_analytics/value_streams',
cycleAnalyticsStageEventsPath: cycleAnalyticsStageEventsPath:
'/groups/:id/-/analytics/value_stream_analytics/stages/:stage_id/records', '/groups/:id/-/analytics/value_stream_analytics/stages/:stage_id/records',
...@@ -168,6 +170,11 @@ export default { ...@@ -168,6 +170,11 @@ export default {
return axios.post(url, data); return axios.post(url, data);
}, },
cycleAnalyticsValueStreams(groupId, data) {
const url = Api.buildUrl(this.cycleAnalyticsValueStreamsPath).replace(':id', groupId);
return axios.get(url, data);
},
cycleAnalyticsStageUrl(stageId, groupId) { cycleAnalyticsStageUrl(stageId, groupId) {
return Api.buildUrl(this.cycleAnalyticsStagePath) return Api.buildUrl(this.cycleAnalyticsStagePath)
.replace(':id', groupId) .replace(':id', groupId)
......
...@@ -4,6 +4,8 @@ module Analytics ...@@ -4,6 +4,8 @@ module Analytics
module CycleAnalytics module CycleAnalytics
module Stages module Stages
class ListService < BaseService class ListService < BaseService
extend ::Gitlab::Utils::Override
def execute def execute
return forbidden unless can?(current_user, :read_group_cycle_analytics, parent) return forbidden unless can?(current_user, :read_group_cycle_analytics, parent)
...@@ -21,6 +23,11 @@ module Analytics ...@@ -21,6 +23,11 @@ module Analytics
scope = scope.by_value_stream(params[:value_stream]) if params[:value_stream] scope = scope.by_value_stream(params[:value_stream]) if params[:value_stream]
scope.for_list scope.for_list
end 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 end
end end
......
...@@ -1019,10 +1019,10 @@ RSpec.describe 'Group Value Stream Analytics', :js do ...@@ -1019,10 +1019,10 @@ RSpec.describe 'Group Value Stream Analytics', :js do
end end
it 'can create a value stream' do it 'can create a value stream' do
page.find_button(_('Create new value stream')).click page.find_button(_('Create new Value Stream')).click
fill_in 'create-value-stream-name', with: custom_value_stream_name fill_in 'create-value-stream-name', with: custom_value_stream_name
page.find_button(_('Create value stream')).click page.find_button(_('Create Value Stream')).click
expect(page).to have_text(_("'%{name}' Value Stream created") % { name: custom_value_stream_name }) expect(page).to have_text(_("'%{name}' Value Stream created") % { name: custom_value_stream_name })
end end
......
import Vuex from 'vuex'; import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlModal } from '@gitlab/ui'; import { GlButton, GlModal, GlNewDropdown as GlDropdown } from '@gitlab/ui';
import store from 'ee/analytics/cycle_analytics/store';
import ValueStreamSelect from 'ee/analytics/cycle_analytics/components/value_stream_select.vue'; import ValueStreamSelect from 'ee/analytics/cycle_analytics/components/value_stream_select.vue';
import { valueStreams } from '../mock_data';
import { findDropdownItemText } from '../helpers';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -12,13 +13,23 @@ describe('ValueStreamSelect', () => { ...@@ -12,13 +13,23 @@ describe('ValueStreamSelect', () => {
const createValueStreamMock = jest.fn(() => Promise.resolve()); const createValueStreamMock = jest.fn(() => Promise.resolve());
const mockEvent = { preventDefault: jest.fn() }; const mockEvent = { preventDefault: jest.fn() };
const mockModalHide = jest.fn();
const mockToastShow = 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, { shallowMount(ValueStreamSelect, {
localVue, localVue,
store, store: fakeStore({ initialState }),
data() { data() {
return { return {
...data, ...data,
...@@ -38,15 +49,57 @@ describe('ValueStreamSelect', () => { ...@@ -38,15 +49,57 @@ describe('ValueStreamSelect', () => {
const findModal = () => wrapper.find(GlModal); const findModal = () => wrapper.find(GlModal);
const submitButtonDisabledState = () => findModal().props('actionPrimary').attributes[1].disabled; const submitButtonDisabledState = () => findModal().props('actionPrimary').attributes[1].disabled;
const submitForm = () => findModal().vm.$emit('primary', mockEvent); const submitForm = () => findModal().vm.$emit('primary', mockEvent);
const findSelectValueStreamDropdown = () => wrapper.find(GlDropdown);
const findSelectValueStreamDropdownOptions = _wrapper => findDropdownItemText(_wrapper);
const findCreateValueStreamButton = () => wrapper.find(GlButton);
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); wrapper = createComponent({
initialState: {
valueStreams,
},
});
}); });
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('with value streams available', () => {
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', () => { describe('Create value stream form', () => {
it('submit button is disabled', () => { it('submit button is disabled', () => {
expect(submitButtonDisabledState()).toBe(true); expect(submitButtonDisabledState()).toBe(true);
...@@ -57,7 +110,6 @@ describe('ValueStreamSelect', () => { ...@@ -57,7 +110,6 @@ describe('ValueStreamSelect', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ data: { name: streamName } }); wrapper = createComponent({ data: { name: streamName } });
wrapper.vm.$refs.modal.hide = mockModalHide;
}); });
it('submit button is enabled', () => { it('submit button is enabled', () => {
...@@ -68,6 +120,7 @@ describe('ValueStreamSelect', () => { ...@@ -68,6 +120,7 @@ describe('ValueStreamSelect', () => {
beforeEach(() => { beforeEach(() => {
submitForm(); submitForm();
}); });
it('calls the "createValueStream" event when submitted', () => { it('calls the "createValueStream" event when submitted', () => {
expect(createValueStreamMock).toHaveBeenCalledWith({ name: streamName }); expect(createValueStreamMock).toHaveBeenCalledWith({ name: streamName });
}); });
...@@ -81,10 +134,6 @@ describe('ValueStreamSelect', () => { ...@@ -81,10 +134,6 @@ describe('ValueStreamSelect', () => {
position: 'top-center', position: 'top-center',
}); });
}); });
it('hides the modal', () => {
expect(mockModalHide).toHaveBeenCalled();
});
}); });
describe('form submission fails', () => { describe('form submission fails', () => {
...@@ -93,11 +142,10 @@ describe('ValueStreamSelect', () => { ...@@ -93,11 +142,10 @@ describe('ValueStreamSelect', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ wrapper = createComponent({
data: { name: streamName }, data: { name: streamName },
methods: { actions: {
createValueStream: createValueStreamMockFail, createValueStream: () => createValueStreamMockFail,
}, },
}); });
wrapper.vm.$refs.modal.hide = mockModalHide;
}); });
it('does not clear the name field', () => { it('does not clear the name field', () => {
...@@ -107,10 +155,6 @@ describe('ValueStreamSelect', () => { ...@@ -107,10 +155,6 @@ describe('ValueStreamSelect', () => {
it('does not display a toast message', () => { it('does not display a toast message', () => {
expect(mockToastShow).not.toHaveBeenCalled(); expect(mockToastShow).not.toHaveBeenCalled();
}); });
it('does not hide the modal', () => {
expect(mockModalHide).not.toHaveBeenCalled();
});
}); });
}); });
}); });
......
import { GlNewDropdownItem as GlDropdownItem } from '@gitlab/ui';
export function renderTotalTime(selector, element, totalTime = {}) { export function renderTotalTime(selector, element, totalTime = {}) {
const { days, hours, mins, seconds } = totalTime; const { days, hours, mins, seconds } = totalTime;
if (days) { if (days) {
...@@ -17,7 +19,14 @@ export function renderTotalTime(selector, element, totalTime = {}) { ...@@ -17,7 +19,14 @@ export function renderTotalTime(selector, element, totalTime = {}) {
export const shouldFlashAMessage = (msg = '') => export const shouldFlashAMessage = (msg = '') =>
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(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 { export default {
renderTotalTime, renderTotalTime,
shouldFlashAMessage, shouldFlashAMessage,
findDropdownItems,
findDropdownItemText,
}; };
...@@ -38,6 +38,8 @@ export const endpoints = { ...@@ -38,6 +38,8 @@ export const endpoints = {
valueStreamData: /analytics\/value_stream_analytics\/value_streams/, 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( export const groupLabels = getJSONFixture(fixtureEndpoints.groupLabels).map(
convertObjectPropsToCamelCase, convertObjectPropsToCamelCase,
); );
......
...@@ -13,6 +13,7 @@ import { ...@@ -13,6 +13,7 @@ import {
endDate, endDate,
customizableStagesAndEvents, customizableStagesAndEvents,
endpoints, endpoints,
valueStreams,
} from '../mock_data'; } from '../mock_data';
const stageData = { events: [] }; const stageData = { events: [] };
...@@ -20,6 +21,7 @@ const error = new Error(`Request failed with status code ${httpStatusCodes.NOT_F ...@@ -20,6 +21,7 @@ const error = new Error(`Request failed with status code ${httpStatusCodes.NOT_F
const flashErrorMessage = 'There was an error while fetching value stream analytics data.'; const flashErrorMessage = 'There was an error while fetching value stream analytics data.';
const [selectedStage] = stages; const [selectedStage] = stages;
const selectedStageSlug = selectedStage.slug; const selectedStageSlug = selectedStage.slug;
const [selectedValueStream] = valueStreams;
const stageEndpoint = ({ stageId }) => const stageEndpoint = ({ stageId }) =>
`/groups/${selectedGroup.fullPath}/-/analytics/value_stream_analytics/stages/${stageId}`; `/groups/${selectedGroup.fullPath}/-/analytics/value_stream_analytics/stages/${stageId}`;
...@@ -58,6 +60,7 @@ describe('Cycle analytics actions', () => { ...@@ -58,6 +60,7 @@ describe('Cycle analytics actions', () => {
${'setFeatureFlags'} | ${'SET_FEATURE_FLAGS'} | ${'featureFlags'} | ${{ hasDurationChart: true }} ${'setFeatureFlags'} | ${'SET_FEATURE_FLAGS'} | ${'featureFlags'} | ${{ hasDurationChart: true }}
${'setSelectedProjects'} | ${'SET_SELECTED_PROJECTS'} | ${'selectedProjectIds'} | ${[10, 20, 30, 40]} ${'setSelectedProjects'} | ${'SET_SELECTED_PROJECTS'} | ${'selectedProjectIds'} | ${[10, 20, 30, 40]}
${'setSelectedStage'} | ${'SET_SELECTED_STAGE'} | ${'selectedStage'} | ${{ id: 'someStageId' }} ${'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 }) => { `('$action should set $stateKey with $payload and type $type', ({ action, type, payload }) => {
return testAction( return testAction(
actions[action], actions[action],
...@@ -133,7 +136,7 @@ describe('Cycle analytics actions', () => { ...@@ -133,7 +136,7 @@ describe('Cycle analytics actions', () => {
beforeEach(() => { beforeEach(() => {
state = { ...state, selectedGroup }; state = { ...state, selectedGroup };
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
mock.onGet(endpoints.stageData).reply(200, { events: [] }); mock.onGet(endpoints.stageData).reply(httpStatusCodes.OK, { events: [] });
}); });
it('dispatches receiveStageDataSuccess with received data on success', () => { it('dispatches receiveStageDataSuccess with received data on success', () => {
...@@ -246,6 +249,7 @@ describe('Cycle analytics actions', () => { ...@@ -246,6 +249,7 @@ describe('Cycle analytics actions', () => {
[], [],
[ [
{ type: 'requestCycleAnalyticsData' }, { type: 'requestCycleAnalyticsData' },
{ type: 'fetchValueStreams' },
{ type: 'fetchGroupStagesAndEvents' }, { type: 'fetchGroupStagesAndEvents' },
{ type: 'fetchStageMedianValues' }, { type: 'fetchStageMedianValues' },
{ type: 'receiveCycleAnalyticsDataSuccess' }, { type: 'receiveCycleAnalyticsDataSuccess' },
...@@ -416,7 +420,7 @@ describe('Cycle analytics actions', () => { ...@@ -416,7 +420,7 @@ describe('Cycle analytics actions', () => {
const payload = { hidden: true }; const payload = { hidden: true };
beforeEach(() => { beforeEach(() => {
mock.onPut(stageEndpoint({ stageId }), payload).replyOnce(200, payload); mock.onPut(stageEndpoint({ stageId }), payload).replyOnce(httpStatusCodes.OK, payload);
state = { selectedGroup }; state = { selectedGroup };
}); });
...@@ -562,7 +566,7 @@ describe('Cycle analytics actions', () => { ...@@ -562,7 +566,7 @@ describe('Cycle analytics actions', () => {
const stageId = 'cool-stage'; const stageId = 'cool-stage';
beforeEach(() => { beforeEach(() => {
mock.onDelete(stageEndpoint({ stageId })).replyOnce(200); mock.onDelete(stageEndpoint({ stageId })).replyOnce(httpStatusCodes.OK);
state = { selectedGroup }; state = { selectedGroup };
}); });
...@@ -614,7 +618,7 @@ describe('Cycle analytics actions', () => { ...@@ -614,7 +618,7 @@ describe('Cycle analytics actions', () => {
const stageId = 'cool-stage'; const stageId = 'cool-stage';
beforeEach(() => { beforeEach(() => {
mock.onDelete(stageEndpoint({ stageId })).replyOnce(200); mock.onDelete(stageEndpoint({ stageId })).replyOnce(httpStatusCodes.OK);
state = { selectedGroup }; state = { selectedGroup };
}); });
...@@ -647,7 +651,7 @@ describe('Cycle analytics actions', () => { ...@@ -647,7 +651,7 @@ describe('Cycle analytics actions', () => {
beforeEach(() => { beforeEach(() => {
state = { ...state, stages: [{ slug: selectedStageSlug }], selectedGroup }; state = { ...state, stages: [{ slug: selectedStageSlug }], selectedGroup };
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
mock.onGet(endpoints.stageMedian).reply(200, { events: [] }); mock.onGet(endpoints.stageMedian).reply(httpStatusCodes.OK, { events: [] });
mockDispatch = jest.fn(); mockDispatch = jest.fn();
}); });
...@@ -875,13 +879,11 @@ describe('Cycle analytics actions', () => { ...@@ -875,13 +879,11 @@ describe('Cycle analytics actions', () => {
payload, payload,
state, state,
[ [
{ type: types.REQUEST_CREATE_VALUE_STREAM },
{ {
type: types.RECEIVE_CREATE_VALUE_STREAM_SUCCESS, type: types.REQUEST_CREATE_VALUE_STREAM,
payload: { status: httpStatusCodes.OK, data: {} },
}, },
], ],
[], [{ type: 'receiveCreateValueStreamSuccess' }],
); );
}); });
}); });
...@@ -909,4 +911,92 @@ describe('Cycle analytics actions', () => { ...@@ -909,4 +911,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(httpStatusCodes.OK, { 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: selectedValueStream.id }],
);
});
});
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 { ...@@ -12,6 +12,7 @@ import {
endDate, endDate,
selectedProjects, selectedProjects,
customizableStagesAndEvents, customizableStagesAndEvents,
valueStreams,
} from '../mock_data'; } from '../mock_data';
let state = null; let state = null;
...@@ -27,6 +28,10 @@ describe('Cycle analytics mutations', () => { ...@@ -27,6 +28,10 @@ describe('Cycle analytics mutations', () => {
it.each` it.each`
mutation | stateKey | value 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.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true} ${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false} ${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false}
...@@ -59,18 +64,33 @@ describe('Cycle analytics mutations', () => { ...@@ -59,18 +64,33 @@ describe('Cycle analytics mutations', () => {
${types.SET_DATE_RANGE} | ${{ startDate, endDate }} | ${{ startDate, endDate }} ${types.SET_DATE_RANGE} | ${{ startDate, endDate }} | ${{ startDate, endDate }}
${types.SET_SELECTED_STAGE} | ${{ id: 'first-stage' }} | ${{ selectedStage: { id: 'first-stage' } }} ${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_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 with payload $payload will update state with $expectedState', '$mutation with payload $payload will update state with $expectedState',
({ mutation, payload, expectedState }) => { ({ mutation, payload, expectedState }) => {
state = { state = { selectedGroup: { fullPath: 'rad-stage' } };
selectedGroup: { fullPath: 'rad-stage' },
};
mutations[mutation](state, payload); mutations[mutation](state, payload);
expect(state).toMatchObject(expectedState); expect(state).toMatchObject(expectedState);
}, },
); );
describe('with value streams available', () => {
it.each`
mutation | payload | expectedState
${types.SET_SELECTED_VALUE_STREAM} | ${valueStreams[1].id} | ${{ selectedValueStream: valueStreams[1] }}
${types.SET_SELECTED_VALUE_STREAM} | ${'fake-id'} | ${{ selectedValueStream: {} }}
`(
'$mutation with payload $payload will update state with $expectedState',
({ mutation, payload, expectedState }) => {
state = { valueStreams };
mutations[mutation](state, payload);
expect(state).toMatchObject(expectedState);
},
);
});
describe(`${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS}`, () => { describe(`${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS}`, () => {
it('will set isLoading=false and errorCode=null', () => { it('will set isLoading=false and errorCode=null', () => {
mutations[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, { mutations[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, {
......
...@@ -334,6 +334,45 @@ describe('Api', () => { ...@@ -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(httpStatus.OK, 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(httpStatus.OK, 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', () => { describe('cycleAnalyticsGroupStagesAndEvents', () => {
it('fetches custom stage events and all stages', done => { it('fetches custom stage events and all stages', done => {
const response = { events: [], stages: [] }; const response = { events: [], stages: [] };
......
...@@ -28,6 +28,10 @@ RSpec.describe Analytics::CycleAnalytics::Stages::ListService do ...@@ -28,6 +28,10 @@ RSpec.describe Analytics::CycleAnalytics::Stages::ListService do
expect(stages.map(&:id)).to all(be_nil) expect(stages.map(&:id)).to all(be_nil)
end 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 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(: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) } let_it_be(:stage2) { create(:cycle_analytics_group_stage, parent: group, relative_position: 3, value_stream: value_stream) }
......
...@@ -6860,6 +6860,9 @@ msgstr "" ...@@ -6860,6 +6860,9 @@ msgstr ""
msgid "Create Project" msgid "Create Project"
msgstr "" msgstr ""
msgid "Create Value Stream"
msgstr ""
msgid "Create a GitLab account first, and then connect it to your %{label} account." msgid "Create a GitLab account first, and then connect it to your %{label} account."
msgstr "" msgstr ""
...@@ -6956,6 +6959,9 @@ msgstr "" ...@@ -6956,6 +6959,9 @@ msgstr ""
msgid "Create new" msgid "Create new"
msgstr "" msgstr ""
msgid "Create new Value Stream"
msgstr ""
msgid "Create new board" msgid "Create new board"
msgstr "" msgstr ""
...@@ -6977,9 +6983,6 @@ msgstr "" ...@@ -6977,9 +6983,6 @@ msgstr ""
msgid "Create new label" msgid "Create new label"
msgstr "" msgstr ""
msgid "Create new value stream"
msgstr ""
msgid "Create new..." msgid "Create new..."
msgstr "" msgstr ""
...@@ -6995,9 +6998,6 @@ msgstr "" ...@@ -6995,9 +6998,6 @@ msgstr ""
msgid "Create snippet" msgid "Create snippet"
msgstr "" msgstr ""
msgid "Create value stream"
msgstr ""
msgid "Create wildcard: %{searchTerm}" msgid "Create wildcard: %{searchTerm}"
msgstr "" msgstr ""
...@@ -9709,7 +9709,7 @@ msgstr "" ...@@ -9709,7 +9709,7 @@ msgstr ""
msgid "Example: @sub\\.company\\.com$" msgid "Example: @sub\\.company\\.com$"
msgstr "" msgstr ""
msgid "Example: My value stream" msgid "Example: My Value Stream"
msgstr "" msgstr ""
msgid "Example: Usage = single query. (Requested) / (Capacity) = multiple queries combined into a formula." msgid "Example: Usage = single query. (Requested) / (Capacity) = multiple queries combined into a formula."
......
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