Commit f856c8d9 authored by Brandon Labuschagne's avatar Brandon Labuschagne

Merge branch '267536-vsa-create-controls' into 'master'

[FE] Separate VSA selector and create form

See merge request gitlab-org/gitlab!48740
parents 92fc6729 6fcbcda2
<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