Commit 78bf43a1 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Adds field validation and specs

Adds additional specs for the create
value stream form

Added mutation and action specs

Adds mutation and action specs for
creating a value stream

Added feature spec for creating a value stream

Adds an rspec test for creating a custom
value stream.

Clean up specs
parent 293a0460
<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: {
GlModalDirective,
},
props: {
isLoading: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
name: '',
errors: { name: [] },
};
},
computed: {
...mapState({ isCreatingValueStream: 'isLoading' }),
...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']),
......@@ -41,6 +67,10 @@ export default {
this.name = '';
});
},
onHandleInput: debounce(function debouncedValidation() {
const { name } = this;
this.errors = validate({ name });
}, 250),
},
};
</script>
......@@ -66,7 +96,21 @@ export default {
:action-cancel="{ text: __('Cancel') }"
@primary.prevent="onSubmit"
>
<gl-form-input id="name" v-model="name" :placeholder="__('Example: My value stream')" />
<gl-form-group
label="Name"
label-for="create-value-stream-name"
:invalid-feedback="invalidFeedback"
:state="isValid"
>
<gl-form-input
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>
......@@ -296,11 +296,6 @@ export const reorderStage = ({ dispatch, state }, initialData) => {
);
};
export const receiveCreateValueStreamSuccess = ({ commit }) => {
// TODO: fetch / update list of value streams
commit(types.RECEIVE_CREATE_VALUE_STREAM_SUCCESS);
};
export const createValueStream = ({ commit, rootState }, data) => {
const {
selectedGroup: { fullPath },
......
......@@ -124,11 +124,14 @@ export default {
},
[types.REQUEST_CREATE_VALUE_STREAM](state) {
state.isCreatingValueStream = true;
state.createValueStreamErrors = {};
},
[types.RECEIVE_CREATE_VALUE_STREAM_ERROR](state) {
[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 = {};
},
};
......@@ -24,6 +24,7 @@ export default () => ({
currentStageEvents: [],
isCreatingValueStream: false,
createValueStreamErrors: {},
stages: [],
summary: [],
......
......@@ -1008,4 +1008,26 @@ RSpec.describe 'Group Value Stream Analytics', :js do
end
end
end
describe 'Create value stream', :js do
custom_value_stream_name = "Test value stream"
before do
visit analytics_cycle_analytics_path
select_group
end
it 'can create a value stream' do
expect(page).to have_text('Create new value stream')
modal_button = page.find_button('Create new value stream')
modal_button.click
fill_in 'create-value-stream-name', with: custom_value_stream_name
page.find_button('Create value stream').click
expect(page).to have_text("'#{custom_value_stream_name}' Value Stream created")
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,62 @@ describe('ValueStreamSelect', () => {
});
describe('with valid fields', () => {
const streamName = 'Cool stream';
beforeEach(async () => {
wrapper = createComponent();
await wrapper.setData({ name: 'Cool stream' });
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`);
});
it('hides the modal', () => {
expect(mockModalHide).toHaveBeenCalled();
});
});
describe('form submission fails', () => {
const createValueStreamMockFail = jest.fn(() => Promise.reject());
beforeEach(async () => {
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 }) => {
......
......@@ -14362,6 +14362,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 ""
......@@ -15329,6 +15332,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