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 {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlForm,
GlFormInput,
GlFormGroup,
GlModal,
GlModalDirective,
} from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { debounce } from 'lodash';
import { sprintf, __ } from '~/locale';
import { DATA_REFETCH_DELAY } from '../../shared/constants';
import ValueStreamForm from './value_stream_form.vue';
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;
const I18N = {
DELETE_NAME: __('Delete %{name}'),
DELETE_CONFIRMATION: __('Are you sure you want to delete "%{name}" Value Stream?'),
DELETED: __("'%{name}' Value Stream deleted"),
DELETE: __('Delete'),
CREATE_VALUE_STREAM: __('Create new Value Stream'),
CANCEL: __('Cancel'),
};
export default {
......@@ -39,38 +28,19 @@ export default {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlForm,
GlFormInput,
GlFormGroup,
GlModal,
ValueStreamForm,
},
directives: {
GlModalDirective,
},
data() {
return {
name: '',
errors: {},
};
},
computed: {
...mapState({
isDeleting: 'isDeletingValueStream',
isCreating: 'isCreatingValueStream',
deleteValueStreamError: 'deleteValueStreamError',
initialFormErrors: 'createValueStreamErrors',
data: 'valueStreams',
selectedValueStream: 'selectedValueStream',
}),
isLoading() {
return this.isDeleting || this.isCreating;
},
isValid() {
return !this.errors.name?.length;
},
invalidFeedback() {
return this.errors.name?.join('\n');
},
hasValueStreams() {
return Boolean(this.data.length);
},
......@@ -83,49 +53,20 @@ export default {
canDeleteSelectedStage() {
return this.selectedValueStream?.isCustom || false;
},
hasFormErrors() {
const { initialFormErrors } = this;
return Boolean(Object.keys(initialFormErrors).length);
},
deleteSelectedText() {
return sprintf(__('Delete %{name}'), { name: this.selectedValueStreamName });
return sprintf(this.$options.I18N.DELETE_NAME, { name: this.selectedValueStreamName });
},
deleteConfirmationText() {
return sprintf(__('Are you sure you want to delete "%{name}" Value Stream?'), {
return sprintf(this.$options.I18N.DELETE_CONFIRMATION, {
name: this.selectedValueStreamName,
});
},
},
watch: {
initialFormErrors(newErrors = {}) {
this.errors = newErrors;
},
},
mounted() {
const { initialFormErrors } = this;
if (this.hasFormErrors) {
this.errors = initialFormErrors;
} else {
this.onHandleInput();
}
},
methods: {
...mapActions(['createValueStream', 'setSelectedValueStream', 'deleteValueStream']),
onSubmit() {
const { name } = this;
return this.createValueStream({ name }).then(() => {
if (!this.hasFormErrors) {
this.$toast.show(sprintf(__("'%{name}' Value Stream created"), { name }), {
position: 'top-center',
});
this.name = '';
}
});
...mapActions(['setSelectedValueStream', 'deleteValueStream']),
onSuccess(message) {
this.$toast.show(message, { position: 'top-center' });
},
onHandleInput: debounce(function debouncedValidation() {
const { name } = this;
this.errors = validate({ name });
}, DATA_REFETCH_DELAY),
isSelected(id) {
return Boolean(this.selectedValueStreamId && this.selectedValueStreamId === id);
},
......@@ -136,17 +77,16 @@ export default {
const name = this.selectedValueStreamName;
return this.deleteValueStream(this.selectedValueStreamId).then(() => {
if (!this.deleteValueStreamError) {
this.$toast.show(sprintf(__("'%{name}' Value Stream deleted"), { name }), {
position: 'top-center',
});
this.onSuccess(sprintf(this.$options.I18N.DELETED, { name }));
}
});
},
},
I18N,
};
</script>
<template>
<gl-form>
<div>
<gl-dropdown
v-if="hasValueStreams"
data-testid="dropdown-value-streams"
......@@ -162,8 +102,8 @@ export default {
>{{ streamName }}</gl-dropdown-item
>
<gl-dropdown-divider />
<gl-dropdown-item v-gl-modal-directive="'create-value-stream-modal'" @click="onHandleInput">{{
__('Create new Value Stream')
<gl-dropdown-item v-gl-modal-directive="'value-stream-form-modal'">{{
$options.I18N.CREATE_VALUE_STREAM
}}</gl-dropdown-item>
<gl-dropdown-item
v-if="canDeleteSelectedStage"
......@@ -173,52 +113,19 @@ export default {
>{{ deleteSelectedText }}</gl-dropdown-item
>
</gl-dropdown>
<gl-button v-else v-gl-modal-directive="'create-value-stream-modal'" @click="onHandleInput">{{
__('Create new Value Stream')
<gl-button v-else v-gl-modal-directive="'value-stream-form-modal'">{{
$options.I18N.CREATE_VALUE_STREAM
}}</gl-button>
<gl-modal
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>
<value-stream-form />
<gl-modal
data-testid="delete-value-stream-modal"
modal-id="delete-value-stream-modal"
:title="__('Delete Value Stream')"
:action-primary="{
text: __('Delete'),
attributes: [{ variant: 'danger' }, { loading: isLoading }],
text: $options.I18N.DELETE,
attributes: [{ variant: 'danger' }, { loading: isDeleting }],
}"
:action-cancel="{ text: __('Cancel') }"
:action-cancel="{ text: $options.I18N.CANCEL }"
@primary.prevent="onDelete"
>
<gl-alert v-if="deleteValueStreamError" variant="danger">{{
......@@ -226,5 +133,5 @@ export default {
}}</gl-alert>
<p>{{ deleteConfirmationText }}</p>
</gl-modal>
</gl-form>
</div>
</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 Vuex from 'vuex';
import ValueStreamSelect from 'ee/analytics/cycle_analytics/components/value_stream_select.vue';
......@@ -17,7 +17,6 @@ describe('ValueStreamSelect', () => {
const mockToastShow = jest.fn();
const streamName = 'Cool stream';
const selectedValueStream = valueStreams[0];
const createValueStreamErrors = { name: ['Name field required'] };
const deleteValueStreamError = 'Cannot delete default value stream';
const fakeStore = ({ initialState = {} }) =>
......@@ -54,14 +53,11 @@ describe('ValueStreamSelect', () => {
});
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 findSelectValueStreamDropdown = () => wrapper.find(GlDropdown);
const findSelectValueStreamDropdownOptions = _wrapper => findDropdownItemText(_wrapper);
const findCreateValueStreamButton = () => wrapper.find(GlButton);
const findDeleteValueStreamButton = () => wrapper.find('[data-testid="delete-value-stream"]');
const findFormGroup = () => wrapper.find(GlFormGroup);
beforeEach(() => {
wrapper = createComponent({
......@@ -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('succeeds', () => {
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