Commit 6fcbcda2 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo Committed by Brandon Labuschagne

Add value stream form specs

Separates the jest test for the value
stream selector and the value stream form
parent e733d1f4
<script>
import { GlForm, GlFormInput, GlFormGroup, GlModal } from '@gitlab/ui';
import { debounce } from 'lodash';
import { mapState, mapActions } from 'vuex';
import { sprintf, __ } from '~/locale';
import { DATA_REFETCH_DELAY } from '../../shared/constants';
const ERRORS = {
MIN_LENGTH: __('Name is required'),
MAX_LENGTH: __('Maximum length 100 characters'),
};
const NAME_MAX_LENGTH = 100;
const validate = ({ name }) => {
const errors = { name: [] };
if (name.length > NAME_MAX_LENGTH) {
errors.name.push(ERRORS.MAX_LENGTH);
}
if (!name.length) {
errors.name.push(ERRORS.MIN_LENGTH);
}
return errors;
};
const I18N = {
CREATE_VALUE_STREAM: __('Create Value Stream'),
CREATED: __("'%{name}' Value Stream created"),
CANCEL: __('Cancel'),
MODAL_TITLE: __('Value Stream Name'),
FIELD_NAME_LABEL: __('Name'),
FIELD_NAME_PLACEHOLDER: __('Example: My Value Stream'),
};
export default {
name: 'ValueStreamForm',
components: {
GlForm,
GlFormInput,
GlFormGroup,
GlModal,
},
props: {
initialData: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
errors: {},
name: '',
...this.initialData,
};
},
computed: {
...mapState({
initialFormErrors: 'createValueStreamErrors',
isCreating: 'isCreatingValueStream',
}),
isValid() {
return !this.errors.name?.length;
},
invalidFeedback() {
return this.errors.name?.join('\n');
},
hasFormErrors() {
const { initialFormErrors } = this;
return Boolean(Object.keys(initialFormErrors).length);
},
isLoading() {
return this.isCreating;
},
primaryProps() {
return {
text: this.$options.I18N.CREATE_VALUE_STREAM,
attributes: [
{ variant: 'success' },
{ disabled: !this.isValid },
{ loading: this.isLoading },
],
};
},
},
watch: {
initialFormErrors(newErrors = {}) {
this.errors = newErrors;
},
},
mounted() {
const { initialFormErrors } = this;
if (this.hasFormErrors) {
this.errors = initialFormErrors;
} else {
this.onHandleInput();
}
},
methods: {
...mapActions(['createValueStream']),
onHandleInput: debounce(function debouncedValidation() {
const { name } = this;
this.errors = validate({ name });
}, DATA_REFETCH_DELAY),
onSubmit() {
const { name } = this;
return this.createValueStream({ name }).then(() => {
if (!this.hasFormErrors) {
this.$toast.show(sprintf(this.$options.I18N.CREATED, { name }), {
position: 'top-center',
});
this.name = '';
}
});
},
},
I18N,
};
</script>
<template>
<gl-modal
data-testid="value-stream-form-modal"
modal-id="value-stream-form-modal"
:title="$options.I18N.MODAL_TITLE"
:action-primary="primaryProps"
:action-cancel="{ text: $options.I18N.CANCEL }"
@primary.prevent="onSubmit"
>
<gl-form>
<gl-form-group
:label="$options.I18N.FIELD_NAME_LABEL"
label-for="create-value-stream-name"
:invalid-feedback="invalidFeedback"
:state="isValid"
>
<gl-form-input
id="create-value-stream-name"
v-model.trim="name"
name="create-value-stream-name"
:placeholder="$options.I18N.FIELD_NAME_PLACEHOLDER"
:state="isValid"
required
@input="onHandleInput"
/>
</gl-form-group>
</gl-form>
</gl-modal>
</template>
...@@ -5,31 +5,20 @@ import { ...@@ -5,31 +5,20 @@ import {
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
GlDropdownDivider, GlDropdownDivider,
GlForm,
GlFormInput,
GlFormGroup,
GlModal, GlModal,
GlModalDirective, GlModalDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { debounce } from 'lodash';
import { sprintf, __ } from '~/locale'; import { sprintf, __ } from '~/locale';
import { DATA_REFETCH_DELAY } from '../../shared/constants'; import ValueStreamForm from './value_stream_form.vue';
const ERRORS = { const I18N = {
MIN_LENGTH: __('Name is required'), DELETE_NAME: __('Delete %{name}'),
MAX_LENGTH: __('Maximum length 100 characters'), DELETE_CONFIRMATION: __('Are you sure you want to delete "%{name}" Value Stream?'),
}; DELETED: __("'%{name}' Value Stream deleted"),
DELETE: __('Delete'),
const validate = ({ name }) => { CREATE_VALUE_STREAM: __('Create new Value Stream'),
const errors = { name: [] }; CANCEL: __('Cancel'),
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 {
...@@ -39,38 +28,19 @@ export default { ...@@ -39,38 +28,19 @@ export default {
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
GlDropdownDivider, GlDropdownDivider,
GlForm,
GlFormInput,
GlFormGroup,
GlModal, GlModal,
ValueStreamForm,
}, },
directives: { directives: {
GlModalDirective, GlModalDirective,
}, },
data() {
return {
name: '',
errors: {},
};
},
computed: { computed: {
...mapState({ ...mapState({
isDeleting: 'isDeletingValueStream', isDeleting: 'isDeletingValueStream',
isCreating: 'isCreatingValueStream',
deleteValueStreamError: 'deleteValueStreamError', deleteValueStreamError: 'deleteValueStreamError',
initialFormErrors: 'createValueStreamErrors',
data: 'valueStreams', data: 'valueStreams',
selectedValueStream: 'selectedValueStream', selectedValueStream: 'selectedValueStream',
}), }),
isLoading() {
return this.isDeleting || this.isCreating;
},
isValid() {
return !this.errors.name?.length;
},
invalidFeedback() {
return this.errors.name?.join('\n');
},
hasValueStreams() { hasValueStreams() {
return Boolean(this.data.length); return Boolean(this.data.length);
}, },
...@@ -83,49 +53,20 @@ export default { ...@@ -83,49 +53,20 @@ export default {
canDeleteSelectedStage() { canDeleteSelectedStage() {
return this.selectedValueStream?.isCustom || false; return this.selectedValueStream?.isCustom || false;
}, },
hasFormErrors() {
const { initialFormErrors } = this;
return Boolean(Object.keys(initialFormErrors).length);
},
deleteSelectedText() { deleteSelectedText() {
return sprintf(__('Delete %{name}'), { name: this.selectedValueStreamName }); return sprintf(this.$options.I18N.DELETE_NAME, { name: this.selectedValueStreamName });
}, },
deleteConfirmationText() { deleteConfirmationText() {
return sprintf(__('Are you sure you want to delete "%{name}" Value Stream?'), { return sprintf(this.$options.I18N.DELETE_CONFIRMATION, {
name: this.selectedValueStreamName, name: this.selectedValueStreamName,
}); });
}, },
}, },
watch: {
initialFormErrors(newErrors = {}) {
this.errors = newErrors;
},
},
mounted() {
const { initialFormErrors } = this;
if (this.hasFormErrors) {
this.errors = initialFormErrors;
} else {
this.onHandleInput();
}
},
methods: { methods: {
...mapActions(['createValueStream', 'setSelectedValueStream', 'deleteValueStream']), ...mapActions(['setSelectedValueStream', 'deleteValueStream']),
onSubmit() { onSuccess(message) {
const { name } = this; this.$toast.show(message, { position: 'top-center' });
return this.createValueStream({ name }).then(() => {
if (!this.hasFormErrors) {
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 });
}, DATA_REFETCH_DELAY),
isSelected(id) { isSelected(id) {
return Boolean(this.selectedValueStreamId && this.selectedValueStreamId === id); return Boolean(this.selectedValueStreamId && this.selectedValueStreamId === id);
}, },
...@@ -136,17 +77,16 @@ export default { ...@@ -136,17 +77,16 @@ export default {
const name = this.selectedValueStreamName; const name = this.selectedValueStreamName;
return this.deleteValueStream(this.selectedValueStreamId).then(() => { return this.deleteValueStream(this.selectedValueStreamId).then(() => {
if (!this.deleteValueStreamError) { if (!this.deleteValueStreamError) {
this.$toast.show(sprintf(__("'%{name}' Value Stream deleted"), { name }), { this.onSuccess(sprintf(this.$options.I18N.DELETED, { name }));
position: 'top-center',
});
} }
}); });
}, },
}, },
I18N,
}; };
</script> </script>
<template> <template>
<gl-form> <div>
<gl-dropdown <gl-dropdown
v-if="hasValueStreams" v-if="hasValueStreams"
data-testid="dropdown-value-streams" data-testid="dropdown-value-streams"
...@@ -162,8 +102,8 @@ export default { ...@@ -162,8 +102,8 @@ export default {
>{{ streamName }}</gl-dropdown-item >{{ streamName }}</gl-dropdown-item
> >
<gl-dropdown-divider /> <gl-dropdown-divider />
<gl-dropdown-item v-gl-modal-directive="'create-value-stream-modal'" @click="onHandleInput">{{ <gl-dropdown-item v-gl-modal-directive="'value-stream-form-modal'">{{
__('Create new Value Stream') $options.I18N.CREATE_VALUE_STREAM
}}</gl-dropdown-item> }}</gl-dropdown-item>
<gl-dropdown-item <gl-dropdown-item
v-if="canDeleteSelectedStage" v-if="canDeleteSelectedStage"
...@@ -173,52 +113,19 @@ export default { ...@@ -173,52 +113,19 @@ export default {
>{{ deleteSelectedText }}</gl-dropdown-item >{{ deleteSelectedText }}</gl-dropdown-item
> >
</gl-dropdown> </gl-dropdown>
<gl-button v-else v-gl-modal-directive="'create-value-stream-modal'" @click="onHandleInput">{{ <gl-button v-else v-gl-modal-directive="'value-stream-form-modal'">{{
__('Create new Value Stream') $options.I18N.CREATE_VALUE_STREAM
}}</gl-button> }}</gl-button>
<gl-modal <value-stream-form />
data-testid="create-value-stream-modal"
modal-id="create-value-stream-modal"
:title="__('Value Stream Name')"
:action-primary="{
text: __('Create Value Stream'),
attributes: [
{ variant: 'success' },
{
disabled: !isValid,
},
{ loading: isLoading },
],
}"
:action-cancel="{ text: __('Cancel') }"
@primary.prevent="onSubmit"
>
<gl-form-group
:label="__('Name')"
label-for="create-value-stream-name"
:invalid-feedback="invalidFeedback"
:state="isValid"
>
<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-modal
data-testid="delete-value-stream-modal" data-testid="delete-value-stream-modal"
modal-id="delete-value-stream-modal" modal-id="delete-value-stream-modal"
:title="__('Delete Value Stream')" :title="__('Delete Value Stream')"
:action-primary="{ :action-primary="{
text: __('Delete'), text: $options.I18N.DELETE,
attributes: [{ variant: 'danger' }, { loading: isLoading }], attributes: [{ variant: 'danger' }, { loading: isDeleting }],
}" }"
:action-cancel="{ text: __('Cancel') }" :action-cancel="{ text: $options.I18N.CANCEL }"
@primary.prevent="onDelete" @primary.prevent="onDelete"
> >
<gl-alert v-if="deleteValueStreamError" variant="danger">{{ <gl-alert v-if="deleteValueStreamError" variant="danger">{{
...@@ -226,5 +133,5 @@ export default { ...@@ -226,5 +133,5 @@ export default {
}}</gl-alert> }}</gl-alert>
<p>{{ deleteConfirmationText }}</p> <p>{{ deleteConfirmationText }}</p>
</gl-modal> </gl-modal>
</gl-form> </div>
</template> </template>
import { GlModal, GlFormGroup } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import ValueStreamForm from 'ee/analytics/cycle_analytics/components/value_stream_form.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('ValueStreamForm', () => {
let wrapper = null;
const createValueStreamMock = jest.fn(() => Promise.resolve());
const mockEvent = { preventDefault: jest.fn() };
const mockToastShow = jest.fn();
const streamName = 'Cool stream';
const createValueStreamErrors = { name: ['Name field required'] };
const fakeStore = ({ initialState = {} }) =>
new Vuex.Store({
state: {
isCreatingValueStream: false,
createValueStreamErrors: {},
...initialState,
},
actions: {
createValueStream: createValueStreamMock,
},
});
const createComponent = ({ data = {}, initialState = {} } = {}) =>
shallowMount(ValueStreamForm, {
localVue,
store: fakeStore({ initialState }),
data() {
return {
...data,
};
},
mocks: {
$toast: {
show: mockToastShow,
},
},
});
const findModal = () => wrapper.find(GlModal);
const createSubmitButtonDisabledState = () =>
findModal().props('actionPrimary').attributes[1].disabled;
const submitModal = () => findModal().vm.$emit('primary', mockEvent);
const findFormGroup = () => wrapper.find(GlFormGroup);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('default state', () => {
beforeEach(() => {
wrapper = createComponent({ initialState: {} });
});
it('submit button is disabled', () => {
expect(createSubmitButtonDisabledState()).toBe(true);
});
});
describe('form errors', () => {
beforeEach(() => {
wrapper = createComponent({
data: { name: streamName },
initialState: {
createValueStreamErrors,
},
});
});
it('renders the error', () => {
expect(findFormGroup().attributes('invalid-feedback')).toEqual(
createValueStreamErrors.name.join('\n'),
);
});
it('submit button is disabled', () => {
expect(createSubmitButtonDisabledState()).toBe(true);
});
});
describe('with valid fields', () => {
beforeEach(() => {
wrapper = createComponent({ data: { name: streamName } });
});
it('submit button is enabled', () => {
expect(createSubmitButtonDisabledState()).toBe(false);
});
describe('form submitted successfully', () => {
beforeEach(() => {
submitModal();
});
it('calls the "createValueStream" event when submitted', () => {
expect(createValueStreamMock).toHaveBeenCalledWith(expect.any(Object), {
name: streamName,
});
});
it('clears the name field', () => {
expect(wrapper.vm.name).toBe('');
});
it('displays a toast message', () => {
expect(mockToastShow).toHaveBeenCalledWith(`'${streamName}' Value Stream created`, {
position: 'top-center',
});
});
});
describe('form submission fails', () => {
beforeEach(() => {
wrapper = createComponent({
data: { name: streamName },
initialState: {
createValueStreamErrors,
},
});
submitModal();
});
it('calls the createValueStream action', () => {
expect(createValueStreamMock).toHaveBeenCalled();
});
it('does not clear the name field', () => {
expect(wrapper.vm.name).toBe(streamName);
});
it('does not display a toast message', () => {
expect(mockToastShow).not.toHaveBeenCalled();
});
});
});
});
import { GlButton, GlDropdown, GlFormGroup } from '@gitlab/ui'; import { GlButton, GlDropdown } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import ValueStreamSelect from 'ee/analytics/cycle_analytics/components/value_stream_select.vue'; import ValueStreamSelect from 'ee/analytics/cycle_analytics/components/value_stream_select.vue';
...@@ -17,7 +17,6 @@ describe('ValueStreamSelect', () => { ...@@ -17,7 +17,6 @@ describe('ValueStreamSelect', () => {
const mockToastShow = jest.fn(); const mockToastShow = jest.fn();
const streamName = 'Cool stream'; const streamName = 'Cool stream';
const selectedValueStream = valueStreams[0]; const selectedValueStream = valueStreams[0];
const createValueStreamErrors = { name: ['Name field required'] };
const deleteValueStreamError = 'Cannot delete default value stream'; const deleteValueStreamError = 'Cannot delete default value stream';
const fakeStore = ({ initialState = {} }) => const fakeStore = ({ initialState = {} }) =>
...@@ -54,14 +53,11 @@ describe('ValueStreamSelect', () => { ...@@ -54,14 +53,11 @@ describe('ValueStreamSelect', () => {
}); });
const findModal = modal => wrapper.find(`[data-testid="${modal}-value-stream-modal"]`); const findModal = modal => wrapper.find(`[data-testid="${modal}-value-stream-modal"]`);
const createSubmitButtonDisabledState = () =>
findModal('create').props('actionPrimary').attributes[1].disabled;
const submitModal = modal => findModal(modal).vm.$emit('primary', mockEvent); const submitModal = modal => findModal(modal).vm.$emit('primary', mockEvent);
const findSelectValueStreamDropdown = () => wrapper.find(GlDropdown); const findSelectValueStreamDropdown = () => wrapper.find(GlDropdown);
const findSelectValueStreamDropdownOptions = _wrapper => findDropdownItemText(_wrapper); const findSelectValueStreamDropdownOptions = _wrapper => findDropdownItemText(_wrapper);
const findCreateValueStreamButton = () => wrapper.find(GlButton); const findCreateValueStreamButton = () => wrapper.find(GlButton);
const findDeleteValueStreamButton = () => wrapper.find('[data-testid="delete-value-stream"]'); const findDeleteValueStreamButton = () => wrapper.find('[data-testid="delete-value-stream"]');
const findFormGroup = () => wrapper.find(GlFormGroup);
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ wrapper = createComponent({
...@@ -156,90 +152,6 @@ describe('ValueStreamSelect', () => { ...@@ -156,90 +152,6 @@ describe('ValueStreamSelect', () => {
}); });
}); });
describe('Create value stream form', () => {
it('submit button is disabled', () => {
expect(createSubmitButtonDisabledState()).toBe(true);
});
describe('form errors', () => {
beforeEach(() => {
wrapper = createComponent({
data: { name: streamName },
initialState: {
createValueStreamErrors,
},
});
});
it('renders the error', () => {
expect(findFormGroup().attributes('invalid-feedback')).toEqual(
createValueStreamErrors.name.join('\n'),
);
});
it('submit button is disabled', () => {
expect(createSubmitButtonDisabledState()).toBe(true);
});
});
describe('with valid fields', () => {
beforeEach(() => {
wrapper = createComponent({ data: { name: streamName } });
});
it('submit button is enabled', () => {
expect(createSubmitButtonDisabledState()).toBe(false);
});
describe('form submitted successfully', () => {
beforeEach(() => {
submitModal('create');
});
it('calls the "createValueStream" event when submitted', () => {
expect(createValueStreamMock).toHaveBeenCalledWith(expect.any(Object), {
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',
});
});
});
describe('form submission fails', () => {
beforeEach(() => {
wrapper = createComponent({
data: { name: streamName },
initialState: {
createValueStreamErrors,
},
});
submitModal('create');
});
it('calls the createValueStream action', () => {
expect(createValueStreamMock).toHaveBeenCalled();
});
it('does not clear the name field', () => {
expect(wrapper.vm.name).toEqual(streamName);
});
it('does not display a toast message', () => {
expect(mockToastShow).not.toHaveBeenCalled();
});
});
});
});
describe('Delete value stream modal', () => { describe('Delete value stream modal', () => {
describe('succeeds', () => { describe('succeeds', () => {
beforeEach(() => { beforeEach(() => {
......
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