Commit 1e5bb495 authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch '221202-fe-create-new-value-stream' into 'master'

[FE] Create new value stream

Closes #221202

See merge request gitlab-org/gitlab!36026
parents 5347ab93 5c0cb34d
<script> <script>
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import { sprintf, __ } from '~/locale';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
import { PROJECTS_PER_PAGE, STAGE_ACTIONS } from '../constants'; import { PROJECTS_PER_PAGE, STAGE_ACTIONS } from '../constants';
import GroupsDropdownFilter from '../../shared/components/groups_dropdown_filter.vue'; import GroupsDropdownFilter from '../../shared/components/groups_dropdown_filter.vue';
...@@ -121,9 +119,6 @@ export default { ...@@ -121,9 +119,6 @@ export default {
isLoadingTypeOfWork() { isLoadingTypeOfWork() {
return this.isLoadingTasksByTypeChartTopLabels || this.isLoadingTasksByTypeChart; return this.isLoadingTasksByTypeChartTopLabels || this.isLoadingTasksByTypeChart;
}, },
isXSBreakpoint() {
return bp.getBreakpointSize() === 'xs';
},
hasDateRangeSet() { hasDateRangeSet() {
return this.startDate && this.endDate; return this.startDate && this.endDate;
}, },
...@@ -200,10 +195,6 @@ export default { ...@@ -200,10 +195,6 @@ export default {
onStageReorder(data) { onStageReorder(data) {
this.reorderStage(data); this.reorderStage(data);
}, },
onCreateValueStream({ name }) {
// stub - this will eventually trigger a vuex action
this.$toast.show(sprintf(__("'%{name}' Value Stream created"), { name }));
},
}, },
multiProjectSelect: true, multiProjectSelect: true,
dateOptions: [7, 30, 90], dateOptions: [7, 30, 90],
...@@ -228,12 +219,7 @@ export default { ...@@ -228,12 +219,7 @@ export default {
<h3>{{ __('Value Stream Analytics') }}</h3> <h3>{{ __('Value Stream Analytics') }}</h3>
<value-stream-select <value-stream-select
v-if="shouldDisplayCreateMultipleValueStreams" v-if="shouldDisplayCreateMultipleValueStreams"
class="gl-align-self-center" class="gl-align-self-start gl-sm-align-self-start gl-mt-0 gl-sm-mt-5"
:class="{
'gl-w-full': isXSBreakpoint,
'gl-mt-5': !isXSBreakpoint,
}"
@create="onCreateValueStream"
/> />
</div> </div>
<div class="mw-100"> <div class="mw-100">
......
<script> <script>
import { GlButton, GlForm, GlFormInput, GlModal, GlModalDirective } from '@gitlab/ui'; import { GlButton, GlForm, GlFormInput, GlFormGroup, GlModal, GlModalDirective } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { sprintf, __ } from '~/locale';
import { debounce } from 'lodash';
const ERRORS = {
MIN_LENGTH: __('Name is required'),
MAX_LENGTH: __('Maximum length 100 characters'),
};
const validate = ({ name }) => {
const errors = { name: [] };
if (name.length > 100) {
errors.name.push(ERRORS.MAX_LENGTH);
}
if (!name.length) {
errors.name.push(ERRORS.MIN_LENGTH);
}
return errors;
};
export default { export default {
components: { components: {
GlButton, GlButton,
GlForm, GlForm,
GlFormInput, GlFormInput,
GlFormGroup,
GlModal, GlModal,
}, },
directives: { directives: {
...@@ -14,21 +34,55 @@ export default { ...@@ -14,21 +34,55 @@ export default {
data() { data() {
return { return {
name: '', name: '',
errors: { name: [] },
}; };
}, },
computed: { computed: {
...mapState({
isLoading: 'isCreatingValueStream',
initialFormErrors: 'createValueStreamErrors',
}),
isValid() { isValid() {
return Boolean(this.name.length); return !this.errors?.name.length;
},
invalidFeedback() {
return this.errors?.name.join('\n');
}, },
}, },
mounted() {
const { initialFormErrors } = this;
if (Object.keys(initialFormErrors).length) {
this.errors = initialFormErrors;
} else {
this.onHandleInput();
}
},
methods: {
...mapActions(['createValueStream']),
onSubmit() {
const { name } = this;
return this.createValueStream({ name }).then(() => {
this.$refs.modal.hide();
this.$toast.show(sprintf(__("'%{name}' Value Stream created"), { name }), {
position: 'top-center',
});
this.name = '';
});
},
onHandleInput: debounce(function debouncedValidation() {
const { name } = this;
this.errors = validate({ name });
}, 250),
},
}; };
</script> </script>
<template> <template>
<gl-form> <gl-form>
<gl-button v-gl-modal-directive="'create-value-stream-modal'">{{ <gl-button v-gl-modal-directive="'create-value-stream-modal'" @click="onHandleInput">{{
__('Create new value stream') __('Create new value stream')
}}</gl-button> }}</gl-button>
<gl-modal <gl-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="{
...@@ -38,12 +92,28 @@ export default { ...@@ -38,12 +92,28 @@ export default {
{ {
disabled: !isValid, disabled: !isValid,
}, },
{ loading: isLoading },
], ],
}" }"
:action-cancel="{ text: __('Cancel') }" :action-cancel="{ text: __('Cancel') }"
@primary="$emit('create', { name })" @primary.prevent="onSubmit"
>
<gl-form-group
:label="__('Name')"
label-for="create-value-stream-name"
:invalid-feedback="invalidFeedback"
:state="isValid"
> >
<gl-form-input id="name" v-model="name" :placeholder="__('Example: My value stream')" /> <gl-form-input
id="create-value-stream-name"
v-model.trim="name"
name="create-value-stream-name"
:placeholder="__('Example: My value stream')"
:state="isValid"
required
@input="onHandleInput"
/>
</gl-form-group>
</gl-modal> </gl-modal>
</gl-form> </gl-form>
</template> </template>
...@@ -295,3 +295,21 @@ export const reorderStage = ({ dispatch, state }, initialData) => { ...@@ -295,3 +295,21 @@ export const reorderStage = ({ dispatch, state }, initialData) => {
dispatch('receiveReorderStageError', { status, responseData }), dispatch('receiveReorderStageError', { status, responseData }),
); );
}; };
export const createValueStream = ({ commit, rootState }, data) => {
const {
selectedGroup: { fullPath },
} = rootState;
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 });
})
.catch(({ response } = {}) => {
const { data: { message, errors } = null } = response;
commit(types.RECEIVE_CREATE_VALUE_STREAM_ERROR, { data, message, errors });
});
};
...@@ -35,3 +35,7 @@ export const INITIALIZE_CYCLE_ANALYTICS_SUCCESS = 'INITIALIZE_CYCLE_ANALYTICS_SU ...@@ -35,3 +35,7 @@ export const INITIALIZE_CYCLE_ANALYTICS_SUCCESS = 'INITIALIZE_CYCLE_ANALYTICS_SU
export const REQUEST_REORDER_STAGE = 'REQUEST_REORDER_STAGE'; export const REQUEST_REORDER_STAGE = 'REQUEST_REORDER_STAGE';
export const RECEIVE_REORDER_STAGE_SUCCESS = 'RECEIVE_REORDER_STAGE_SUCCESS'; export const RECEIVE_REORDER_STAGE_SUCCESS = 'RECEIVE_REORDER_STAGE_SUCCESS';
export const RECEIVE_REORDER_STAGE_ERROR = 'RECEIVE_REORDER_STAGE_ERROR'; export const RECEIVE_REORDER_STAGE_ERROR = 'RECEIVE_REORDER_STAGE_ERROR';
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_ERROR = 'RECEIVE_CREATE_VALUE_STREAM_ERROR';
...@@ -122,4 +122,16 @@ export default { ...@@ -122,4 +122,16 @@ export default {
state.selectedMilestone = selectedMilestone; state.selectedMilestone = selectedMilestone;
state.selectedLabels = selectedLabels; state.selectedLabels = selectedLabels;
}, },
[types.REQUEST_CREATE_VALUE_STREAM](state) {
state.isCreatingValueStream = true;
state.createValueStreamErrors = {};
},
[types.RECEIVE_CREATE_VALUE_STREAM_ERROR](state, errors = {}) {
state.isCreatingValueStream = false;
state.createValueStreamErrors = errors;
},
[types.RECEIVE_CREATE_VALUE_STREAM_SUCCESS](state) {
state.isCreatingValueStream = false;
state.createValueStreamErrors = {};
},
}; };
...@@ -23,6 +23,9 @@ export default () => ({ ...@@ -23,6 +23,9 @@ export default () => ({
currentStageEvents: [], currentStageEvents: [],
isCreatingValueStream: false,
createValueStreamErrors: {},
stages: [], stages: [],
summary: [], summary: [],
medians: {}, medians: {},
......
...@@ -15,6 +15,7 @@ export default { ...@@ -15,6 +15,7 @@ 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',
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',
cycleAnalyticsStageMedianPath: cycleAnalyticsStageMedianPath:
...@@ -162,6 +163,11 @@ export default { ...@@ -162,6 +163,11 @@ export default {
return axios.post(url, data); return axios.post(url, data);
}, },
cycleAnalyticsCreateValueStream(groupId, data) {
const url = Api.buildUrl(this.cycleAnalyticsValueStreamsPath).replace(':id', groupId);
return axios.post(url, data);
},
cycleAnalyticsStageUrl(stageId, groupId) { cycleAnalyticsStageUrl(stageId, groupId) {
return Api.buildUrl(this.cycleAnalyticsStagePath) return Api.buildUrl(this.cycleAnalyticsStagePath)
.replace(':id', groupId) .replace(':id', groupId)
......
...@@ -1008,4 +1008,23 @@ RSpec.describe 'Group Value Stream Analytics', :js do ...@@ -1008,4 +1008,23 @@ RSpec.describe 'Group Value Stream Analytics', :js do
end end
end end
end end
describe 'Create value stream', :js do
let(:custom_value_stream_name) { "Test value stream" }
before do
visit analytics_cycle_analytics_path
select_group
end
it 'can create a value stream' do
page.find_button(_('Create new value stream')).click
fill_in 'create-value-stream-name', with: custom_value_stream_name
page.find_button(_('Create value stream')).click
expect(page).to have_text(_("'%{name}' Value Stream created") % { name: custom_value_stream_name })
end
end
end end
...@@ -279,14 +279,6 @@ describe('Cycle Analytics component', () => { ...@@ -279,14 +279,6 @@ describe('Cycle Analytics component', () => {
it('displays the create multiple value streams button', () => { it('displays the create multiple value streams button', () => {
displaysCreateValueStream(true); displaysCreateValueStream(true);
}); });
it('displays a toast message when value stream is created', () => {
wrapper.find(ValueStreamSelect).vm.$emit('create', { name: 'cool new stream' });
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
"'cool new stream' Value Stream created",
);
});
}); });
}); });
......
import { shallowMount } from '@vue/test-utils'; import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlModal } from '@gitlab/ui'; 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 ValueStreamSelect from 'ee/analytics/cycle_analytics/components/value_stream_select.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('ValueStreamSelect', () => { describe('ValueStreamSelect', () => {
let wrapper = null; let wrapper = null;
const createComponent = () => shallowMount(ValueStreamSelect, {}); const createValueStreamMock = jest.fn(() => Promise.resolve());
const mockEvent = { preventDefault: jest.fn() };
const mockModalHide = jest.fn();
const mockToastShow = jest.fn();
const createComponent = ({ data = {}, methods = {} } = {}) =>
shallowMount(ValueStreamSelect, {
localVue,
store,
data() {
return {
...data,
};
},
methods: {
createValueStream: createValueStreamMock,
...methods,
},
mocks: {
$toast: {
show: mockToastShow,
},
},
});
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);
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); wrapper = createComponent();
...@@ -23,18 +53,64 @@ describe('ValueStreamSelect', () => { ...@@ -23,18 +53,64 @@ describe('ValueStreamSelect', () => {
}); });
describe('with valid fields', () => { describe('with valid fields', () => {
beforeEach(async () => { const streamName = 'Cool stream';
wrapper = createComponent();
await wrapper.setData({ name: 'Cool stream' }); beforeEach(() => {
wrapper = createComponent({ data: { name: streamName } });
wrapper.vm.$refs.modal.hide = mockModalHide;
}); });
it('submit button is enabled', () => { it('submit button is enabled', () => {
expect(submitButtonDisabledState()).toBe(false); expect(submitButtonDisabledState()).toBe(false);
}); });
it('emits the "create" event when submitted', () => { describe('form submitted successfully', () => {
findModal().vm.$emit('primary'); beforeEach(() => {
expect(wrapper.emitted().create[0]).toEqual([{ name: 'Cool stream' }]); submitForm();
});
it('calls the "createValueStream" event when submitted', () => {
expect(createValueStreamMock).toHaveBeenCalledWith({ name: streamName });
});
it('clears the name field', () => {
expect(wrapper.vm.name).toEqual('');
});
it('displays a toast message', () => {
expect(mockToastShow).toHaveBeenCalledWith(`'${streamName}' Value Stream created`, {
position: 'top-center',
});
});
it('hides the modal', () => {
expect(mockModalHide).toHaveBeenCalled();
});
});
describe('form submission fails', () => {
const createValueStreamMockFail = jest.fn(() => Promise.reject());
beforeEach(() => {
wrapper = createComponent({
data: { name: streamName },
methods: {
createValueStream: createValueStreamMockFail,
},
});
wrapper.vm.$refs.modal.hide = mockModalHide;
});
it('does not clear the name field', () => {
expect(wrapper.vm.name).toEqual(streamName);
});
it('does not display a toast message', () => {
expect(mockToastShow).not.toHaveBeenCalled();
});
it('does not hide the modal', () => {
expect(mockModalHide).not.toHaveBeenCalled();
});
}); });
}); });
}); });
......
...@@ -35,6 +35,7 @@ export const endpoints = { ...@@ -35,6 +35,7 @@ export const endpoints = {
baseStagesEndpoint: /analytics\/value_stream_analytics\/stages$/, baseStagesEndpoint: /analytics\/value_stream_analytics\/stages$/,
tasksByTypeData: /analytics\/type_of_work\/tasks_by_type/, tasksByTypeData: /analytics\/type_of_work\/tasks_by_type/,
tasksByTypeTopLabelsData: /analytics\/type_of_work\/tasks_by_type\/top_labels/, tasksByTypeTopLabelsData: /analytics\/type_of_work\/tasks_by_type\/top_labels/,
valueStreamData: /analytics\/value_stream_analytics\/value_streams/,
}; };
export const groupLabels = getJSONFixture(fixtureEndpoints.groupLabels).map( export const groupLabels = getJSONFixture(fixtureEndpoints.groupLabels).map(
......
...@@ -856,4 +856,57 @@ describe('Cycle analytics actions', () => { ...@@ -856,4 +856,57 @@ describe('Cycle analytics actions', () => {
); );
}); });
}); });
describe('createValueStream', () => {
const payload = { name: 'cool value stream' };
beforeEach(() => {
state = { selectedGroup };
});
describe('with no errors', () => {
beforeEach(() => {
mock.onPost(endpoints.valueStreamData).replyOnce(httpStatusCodes.OK, {});
});
it(`commits the ${types.REQUEST_CREATE_VALUE_STREAM} and ${types.RECEIVE_CREATE_VALUE_STREAM_SUCCESS} actions`, () => {
return testAction(
actions.createValueStream,
payload,
state,
[
{ type: types.REQUEST_CREATE_VALUE_STREAM },
{
type: types.RECEIVE_CREATE_VALUE_STREAM_SUCCESS,
payload: { status: httpStatusCodes.OK, data: {} },
},
],
[],
);
});
});
describe('with errors', () => {
const resp = { message: 'error', errors: {} };
beforeEach(() => {
mock.onPost(endpoints.valueStreamData).replyOnce(httpStatusCodes.NOT_FOUND, resp);
});
it(`commits the ${types.REQUEST_CREATE_VALUE_STREAM} and ${types.RECEIVE_CREATE_VALUE_STREAM_ERROR} actions `, () => {
return testAction(
actions.createValueStream,
payload,
state,
[
{ type: types.REQUEST_CREATE_VALUE_STREAM },
{
type: types.RECEIVE_CREATE_VALUE_STREAM_ERROR,
payload: { data: { ...payload }, ...resp },
},
],
[],
);
});
});
});
}); });
...@@ -40,6 +40,10 @@ describe('Cycle analytics mutations', () => { ...@@ -40,6 +40,10 @@ describe('Cycle analytics mutations', () => {
${types.RECEIVE_REMOVE_STAGE_RESPONSE} | ${'isLoading'} | ${false} ${types.RECEIVE_REMOVE_STAGE_RESPONSE} | ${'isLoading'} | ${false}
${types.REQUEST_STAGE_MEDIANS} | ${'medians'} | ${{}} ${types.REQUEST_STAGE_MEDIANS} | ${'medians'} | ${{}}
${types.RECEIVE_STAGE_MEDIANS_ERROR} | ${'medians'} | ${{}} ${types.RECEIVE_STAGE_MEDIANS_ERROR} | ${'medians'} | ${{}}
${types.REQUEST_CREATE_VALUE_STREAM} | ${'isCreatingValueStream'} | ${true}
${types.RECEIVE_CREATE_VALUE_STREAM_SUCCESS} | ${'isCreatingValueStream'} | ${false}
${types.REQUEST_CREATE_VALUE_STREAM} | ${'createValueStreamErrors'} | ${{}}
${types.RECEIVE_CREATE_VALUE_STREAM_SUCCESS} | ${'createValueStreamErrors'} | ${{}}
${types.INITIALIZE_CYCLE_ANALYTICS_SUCCESS} | ${'isLoading'} | ${false} ${types.INITIALIZE_CYCLE_ANALYTICS_SUCCESS} | ${'isLoading'} | ${false}
`('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => { `('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => {
mutations[mutation](state); mutations[mutation](state);
...@@ -54,6 +58,7 @@ describe('Cycle analytics mutations', () => { ...@@ -54,6 +58,7 @@ describe('Cycle analytics mutations', () => {
${types.SET_SELECTED_PROJECTS} | ${selectedProjects} | ${{ selectedProjects }} ${types.SET_SELECTED_PROJECTS} | ${selectedProjects} | ${{ selectedProjects }}
${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 }}
`( `(
'$mutation with payload $payload will update state with $expectedState', '$mutation with payload $payload will update state with $expectedState',
({ mutation, payload, expectedState }) => { ({ mutation, payload, expectedState }) => {
......
...@@ -14393,6 +14393,9 @@ msgstr "" ...@@ -14393,6 +14393,9 @@ msgstr ""
msgid "Maximum job timeout has a value which could not be accepted" msgid "Maximum job timeout has a value which could not be accepted"
msgstr "" msgstr ""
msgid "Maximum length 100 characters"
msgstr ""
msgid "Maximum lifetime allowable for Personal Access Tokens is active, your expire date must be set before %{maximum_allowable_date}." msgid "Maximum lifetime allowable for Personal Access Tokens is active, your expire date must be set before %{maximum_allowable_date}."
msgstr "" msgstr ""
...@@ -15360,6 +15363,9 @@ msgstr "" ...@@ -15360,6 +15363,9 @@ msgstr ""
msgid "Name has already been taken" msgid "Name has already been taken"
msgstr "" msgstr ""
msgid "Name is required"
msgstr ""
msgid "Name new label" msgid "Name new label"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment