diff --git a/src/components/base/form/form_input/form_input.spec.js b/src/components/base/form/form_input/form_input.spec.js index f0091e6098af8e329003f5cd0e3a112a87539d38..f318157dca20e39730e477afaab70190f1c25633 100644 --- a/src/components/base/form/form_input/form_input.spec.js +++ b/src/components/base/form/form_input/form_input.spec.js @@ -1,12 +1,15 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount, shallowMount } from '@vue/test-utils'; import GlFormInput from './form_input.vue'; import { formInputSizes } from '../../../../utils/constants'; +const modelEvent = GlFormInput.model.event; +const newValue = 'foo'; + describe('GlFormInput', () => { let wrapper; - const createComponent = propsData => { - wrapper = shallowMount(GlFormInput, { + const createComponent = (propsData = {}, mountFn = shallowMount) => { + wrapper = mountFn(GlFormInput, { propsData, }); }; @@ -38,4 +41,66 @@ describe('GlFormInput', () => { expect(wrapper.classes()).toEqual(['gl-form-input']); }); }); + + describe('v-model', () => { + beforeEach(() => { + createComponent({}, mount); + + wrapper.setValue(newValue); + }); + + it('synchronously emits an update event', () => { + expect(wrapper.emitted('update')).toEqual([[newValue]]); + }); + + it('synchronously updates model', () => { + expect(wrapper.emitted(modelEvent)).toEqual([[newValue]]); + }); + }); + + describe('debounce', () => { + describe.each([10, 100, 1000])('given a debounce of %dms', debounce => { + beforeEach(() => { + jest.useFakeTimers(); + + createComponent({ debounce }, mount); + + wrapper.setValue(newValue); + }); + + it('synchronously emits an update event', () => { + expect(wrapper.emitted('update')).toEqual([[newValue]]); + }); + + it('emits a model event after the debounce delay', () => { + // Just before debounce completes + jest.advanceTimersByTime(debounce - 1); + expect(wrapper.emitted(modelEvent)).toBe(undefined); + + // Exactly when debounce completes + jest.advanceTimersByTime(1); + expect(wrapper.emitted(modelEvent)).toEqual([[newValue]]); + }); + }); + }); + + describe('lazy', () => { + beforeEach(() => { + createComponent({ lazy: true }, mount); + + wrapper.setValue(newValue); + }); + + it('synchronously emits an update event', () => { + expect(wrapper.emitted('update')).toEqual([[newValue]]); + }); + + it.each(['change', 'blur'])('updates model after %s event', event => { + expect(wrapper.emitted(modelEvent)).toBe(undefined); + + wrapper.trigger(event); + + expect(wrapper.emitted(modelEvent)).toEqual([[newValue]]); + }); + }); }); diff --git a/src/components/base/form/form_input/form_input.vue b/src/components/base/form/form_input/form_input.vue index 0fa3b099ad67eaaa57ec5b97c374b8d1f850c3bd..88cde3d1727704e432793f784a7b66354efcd741 100644 --- a/src/components/base/form/form_input/form_input.vue +++ b/src/components/base/form/form_input/form_input.vue @@ -2,11 +2,17 @@ import { BFormInput } from 'bootstrap-vue'; import { formInputSizes } from '../../../../utils/constants'; +const model = { + prop: 'value', + event: 'input', +}; + export default { components: { BFormInput, }, inheritAttrs: false, + model, props: { size: { type: String, @@ -21,10 +27,23 @@ export default { [`gl-form-input-${this.size}`]: this.size !== null, }; }, + listeners() { + return { + ...this.$listeners, + // Swap purpose of input and update events from underlying BFormInput. + // See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/631. + input: (...args) => { + this.$emit('update', ...args); + }, + update: (...args) => { + this.$emit(model.event, ...args); + }, + }; + }, }, }; diff --git a/src/components/base/search_box_by_type/search_box_by_type.spec.js b/src/components/base/search_box_by_type/search_box_by_type.spec.js index b78396c04889f110ef3df40caca44f321064e1ee..63a887bc6c77843f016a4c6fe70fb5fd4b08db9c 100644 --- a/src/components/base/search_box_by_type/search_box_by_type.spec.js +++ b/src/components/base/search_box_by_type/search_box_by_type.spec.js @@ -1,26 +1,31 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount, shallowMount } from '@vue/test-utils'; import SearchBoxByType from './search_box_by_type.vue'; import LoadingIcon from '../loading_icon/loading_icon.vue'; import ClearIcon from '~/components/shared_components/clear_icon_button/clear_icon_button.vue'; +const modelEvent = SearchBoxByType.model.event; +const newValue = 'new value'; + describe('search box by type component', () => { let wrapper; - const createComponent = propsData => { - wrapper = shallowMount(SearchBoxByType, { propsData }); + const createComponent = (propsData, mountFn = shallowMount) => { + wrapper = mountFn(SearchBoxByType, { propsData }); }; const findClearIcon = () => wrapper.find(ClearIcon); - - beforeEach(() => { - createComponent({ value: 'somevalue' }); - }); + const findInput = () => wrapper.find({ ref: 'input' }); afterEach(() => { wrapper.destroy(); + wrapper = null; }); describe('clear icon component', () => { + beforeEach(() => { + createComponent({ value: 'somevalue' }); + }); + it('is not rendered when value is empty', () => { createComponent({ value: '' }); expect(findClearIcon().exists()).toBe(false); @@ -38,16 +43,59 @@ describe('search box by type component', () => { }); describe('v-model', () => { - it('syncs localValue to value prop', () => { - wrapper.setProps({ value: 'new value' }); + beforeEach(() => { + createComponent({ value: 'somevalue' }, mount); + }); + + it('syncs value prop to input value', async () => { + wrapper.setProps({ value: newValue }); + await wrapper.vm.$nextTick(); + + expect(findInput().element.value).toEqual(newValue); + }); + + it(`emits ${modelEvent} event when input value changes`, () => { + findInput().setValue(newValue); + + expect(wrapper.emitted().input).toEqual([[newValue]]); + }); + }); + + describe('debounce', () => { + describe.each([10, 100, 1000])('given a debounce of %dms', debounce => { + beforeEach(() => { + jest.useFakeTimers(); + + createComponent({ debounce }, mount); + + findInput().setValue(newValue); + }); + + it(`emits a ${modelEvent} after the debounce delay`, () => { + // Just before debounce completes + jest.advanceTimersByTime(debounce - 1); + expect(wrapper.emitted(modelEvent)).toBe(undefined); - expect(wrapper.vm.localValue).toEqual('new value'); + // Exactly when debounce completes + jest.advanceTimersByTime(1); + expect(wrapper.emitted(modelEvent)).toEqual([[newValue]]); + }); }); + }); + + describe('lazy', () => { + beforeEach(() => { + createComponent({ lazy: true }, mount); + + findInput().setValue(newValue); + }); + + it.each(['change', 'blur'])(`emits ${modelEvent} event after input's %s event`, event => { + expect(wrapper.emitted(modelEvent)).toBe(undefined); - it('emits input event when localValue changes', () => { - wrapper.vm.localValue = 'new value'; + findInput().trigger(event); - expect(wrapper.emitted().input).toEqual([['new value']]); + expect(wrapper.emitted(modelEvent)).toEqual([[newValue]]); }); }); diff --git a/src/components/base/search_box_by_type/search_box_by_type.vue b/src/components/base/search_box_by_type/search_box_by_type.vue index 8bd37a18dc4335ba2894b3a256ac83e17605d37c..c26df9be4323dd1221ccf04914e0e3511d3d305c 100644 --- a/src/components/base/search_box_by_type/search_box_by_type.vue +++ b/src/components/base/search_box_by_type/search_box_by_type.vue @@ -4,6 +4,11 @@ import GlIcon from '../icon/icon.vue'; import GlFormInput from '../form/form_input/form_input.vue'; import GlLoadingIcon from '../loading_icon/loading_icon.vue'; +const model = { + prop: 'value', + event: 'input', +}; + export default { components: { GlClearIconButton, @@ -12,6 +17,7 @@ export default { GlLoadingIcon, }, inheritAttrs: false, + model, props: { value: { type: String,