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>
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { mapActions, mapState, mapGetters } from 'vuex';
import { sprintf, __ } from '~/locale';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
import { PROJECTS_PER_PAGE, STAGE_ACTIONS } from '../constants';
import GroupsDropdownFilter from '../../shared/components/groups_dropdown_filter.vue';
......@@ -121,9 +119,6 @@ export default {
isLoadingTypeOfWork() {
return this.isLoadingTasksByTypeChartTopLabels || this.isLoadingTasksByTypeChart;
},
isXSBreakpoint() {
return bp.getBreakpointSize() === 'xs';
},
hasDateRangeSet() {
return this.startDate && this.endDate;
},
......@@ -200,10 +195,6 @@ export default {
onStageReorder(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,
dateOptions: [7, 30, 90],
......@@ -228,12 +219,7 @@ export default {
<h3>{{ __('Value Stream Analytics') }}</h3>
<value-stream-select
v-if="shouldDisplayCreateMultipleValueStreams"
class="gl-align-self-center"
:class="{
'gl-w-full': isXSBreakpoint,
'gl-mt-5': !isXSBreakpoint,
}"
@create="onCreateValueStream"
class="gl-align-self-start gl-sm-align-self-start gl-mt-0 gl-sm-mt-5"
/>
</div>
<div class="mw-100">
......
<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 {
components: {
GlButton,
GlForm,
GlFormInput,
GlFormGroup,
GlModal,
},
directives: {
......@@ -14,21 +34,55 @@ export default {
data() {
return {
name: '',
errors: { name: [] },
};
},
computed: {
...mapState({
isLoading: 'isCreatingValueStream',
initialFormErrors: 'createValueStreamErrors',
}),
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>
<template>
<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')
}}</gl-button>
<gl-modal
ref="modal"
modal-id="create-value-stream-modal"
:title="__('Value Stream Name')"
:action-primary="{
......@@ -38,12 +92,28 @@ export default {
{
disabled: !isValid,
},
{ loading: isLoading },
],
}"
: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-form>
</template>
......@@ -295,3 +295,21 @@ export const reorderStage = ({ dispatch, state }, initialData) => {
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
export const REQUEST_REORDER_STAGE = 'REQUEST_REORDER_STAGE';
export const RECEIVE_REORDER_STAGE_SUCCESS = 'RECEIVE_REORDER_STAGE_SUCCESS';
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 {
state.selectedMilestone = selectedMilestone;
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 () => ({
currentStageEvents: [],
isCreatingValueStream: false,
createValueStreamErrors: {},
stages: [],
summary: [],
medians: {},
......
......@@ -15,6 +15,7 @@ export default {
cycleAnalyticsSummaryDataPath: '/groups/:id/-/analytics/value_stream_analytics/summary',
cycleAnalyticsTimeSummaryDataPath: '/groups/:id/-/analytics/value_stream_analytics/time_summary',
cycleAnalyticsGroupStagesAndEventsPath: '/groups/:id/-/analytics/value_stream_analytics/stages',
cycleAnalyticsValueStreamsPath: '/groups/:id/-/analytics/value_stream_analytics/value_streams',
cycleAnalyticsStageEventsPath:
'/groups/:id/-/analytics/value_stream_analytics/stages/:stage_id/records',
cycleAnalyticsStageMedianPath:
......@@ -162,6 +163,11 @@ export default {
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) {
return Api.buildUrl(this.cycleAnalyticsStagePath)
.replace(':id', groupId)
......
......@@ -1008,4 +1008,23 @@ RSpec.describe 'Group Value Stream Analytics', :js do
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
......@@ -279,14 +279,6 @@ describe('Cycle Analytics component', () => {
it('displays the create multiple value streams button', () => {
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 store from 'ee/analytics/cycle_analytics/store';
import ValueStreamSelect from 'ee/analytics/cycle_analytics/components/value_stream_select.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('ValueStreamSelect', () => {
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 submitButtonDisabledState = () => findModal().props('actionPrimary').attributes[1].disabled;
const submitForm = () => findModal().vm.$emit('primary', mockEvent);
beforeEach(() => {
wrapper = createComponent();
......@@ -23,18 +53,64 @@ describe('ValueStreamSelect', () => {
});
describe('with valid fields', () => {
beforeEach(async () => {
wrapper = createComponent();
await wrapper.setData({ name: 'Cool stream' });
const streamName = 'Cool stream';
beforeEach(() => {
wrapper = createComponent({ data: { name: streamName } });
wrapper.vm.$refs.modal.hide = mockModalHide;
});
it('submit button is enabled', () => {
expect(submitButtonDisabledState()).toBe(false);
});
it('emits the "create" event when submitted', () => {
findModal().vm.$emit('primary');
expect(wrapper.emitted().create[0]).toEqual([{ name: 'Cool stream' }]);
describe('form submitted successfully', () => {
beforeEach(() => {
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 = {
baseStagesEndpoint: /analytics\/value_stream_analytics\/stages$/,
tasksByTypeData: /analytics\/type_of_work\/tasks_by_type/,
tasksByTypeTopLabelsData: /analytics\/type_of_work\/tasks_by_type\/top_labels/,
valueStreamData: /analytics\/value_stream_analytics\/value_streams/,
};
export const groupLabels = getJSONFixture(fixtureEndpoints.groupLabels).map(
......
......@@ -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', () => {
${types.RECEIVE_REMOVE_STAGE_RESPONSE} | ${'isLoading'} | ${false}
${types.REQUEST_STAGE_MEDIANS} | ${'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}
`('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => {
mutations[mutation](state);
......@@ -54,6 +58,7 @@ 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.RECEIVE_CREATE_VALUE_STREAM_ERROR} | ${{ name: ['is required'] }} | ${{ createValueStreamErrors: { name: ['is required'] }, isCreatingValueStream: false }}
`(
'$mutation with payload $payload will update state with $expectedState',
({ mutation, payload, expectedState }) => {
......
......@@ -14393,6 +14393,9 @@ msgstr ""
msgid "Maximum job timeout has a value which could not be accepted"
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}."
msgstr ""
......@@ -15360,6 +15363,9 @@ msgstr ""
msgid "Name has already been taken"
msgstr ""
msgid "Name is required"
msgstr ""
msgid "Name new label"
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