diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-06-18 11:18:50 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-06-18 11:18:50 +0000 |
commit | 8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781 (patch) | |
tree | a77e7fe7a93de11213032ed4ab1f33a3db51b738 /spec/frontend/vue_shared/components | |
parent | 00b35af3db1abfe813a778f643dad221aad51fca (diff) | |
download | gitlab-ce-8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781.tar.gz |
Add latest changes from gitlab-org/gitlab@13-1-stable-ee
Diffstat (limited to 'spec/frontend/vue_shared/components')
28 files changed, 2576 insertions, 337 deletions
diff --git a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js index 98962918b49..e46c63a1a32 100644 --- a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js +++ b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js @@ -1,7 +1,13 @@ -import * as dateTimePickerLib from '~/vue_shared/components/date_time_picker/date_time_picker_lib'; +import timezoneMock from 'timezone-mock'; + +import { + isValidInputString, + inputStringToIsoDate, + isoDateToInputString, +} from '~/vue_shared/components/date_time_picker/date_time_picker_lib'; describe('date time picker lib', () => { - describe('isValidDate', () => { + describe('isValidInputString', () => { [ { input: '2019-09-09T00:00:00.000Z', @@ -48,121 +54,137 @@ describe('date time picker lib', () => { output: false, }, ].forEach(({ input, output }) => { - it(`isValidDate return ${output} for ${input}`, () => { - expect(dateTimePickerLib.isValidDate(input)).toBe(output); + it(`isValidInputString return ${output} for ${input}`, () => { + expect(isValidInputString(input)).toBe(output); }); }); }); - describe('stringToISODate', () => { - ['', 'null', undefined, 'abc'].forEach(input => { + describe('inputStringToIsoDate', () => { + [ + '', + 'null', + undefined, + 'abc', + 'xxxx-xx-xx', + '9999-99-19', + '2019-19-23', + '2019-09-23 x', + '2019-09-29 24:24:24', + ].forEach(input => { it(`throws error for invalid input like ${input}`, () => { - expect(() => dateTimePickerLib.stringToISODate(input)).toThrow(); + expect(() => inputStringToIsoDate(input)).toThrow(); }); }); + [ { - input: '2019-09-09 01:01:01', - output: '2019-09-09T01:01:01Z', + input: '2019-09-08 01:01:01', + output: '2019-09-08T01:01:01Z', }, { - input: '2019-09-09 00:00:00', - output: '2019-09-09T00:00:00Z', + input: '2019-09-08 00:00:00', + output: '2019-09-08T00:00:00Z', }, { - input: '2019-09-09 23:59:59', - output: '2019-09-09T23:59:59Z', + input: '2019-09-08 23:59:59', + output: '2019-09-08T23:59:59Z', }, { - input: '2019-09-09', - output: '2019-09-09T00:00:00Z', + input: '2019-09-08', + output: '2019-09-08T00:00:00Z', }, - ].forEach(({ input, output }) => { - it(`returns ${output} from ${input}`, () => { - expect(dateTimePickerLib.stringToISODate(input)).toBe(output); - }); - }); - }); - - describe('truncateZerosInDateTime', () => { - [ { - input: '', - output: '', + input: '2019-09-08', + output: '2019-09-08T00:00:00Z', }, { - input: '2019-10-10', - output: '2019-10-10', + input: '2019-09-08 00:00:00', + output: '2019-09-08T00:00:00Z', }, { - input: '2019-10-10 00:00:01', - output: '2019-10-10 00:00:01', + input: '2019-09-08 23:24:24', + output: '2019-09-08T23:24:24Z', }, { - input: '2019-10-10 00:00:00', - output: '2019-10-10', + input: '2019-09-08 0:0:0', + output: '2019-09-08T00:00:00Z', }, ].forEach(({ input, output }) => { - it(`truncateZerosInDateTime return ${output} for ${input}`, () => { - expect(dateTimePickerLib.truncateZerosInDateTime(input)).toBe(output); + it(`returns ${output} from ${input}`, () => { + expect(inputStringToIsoDate(input)).toBe(output); }); }); + + describe('timezone formatting', () => { + const value = '2019-09-08 01:01:01'; + const utcResult = '2019-09-08T01:01:01Z'; + const localResult = '2019-09-08T08:01:01Z'; + + test.each` + val | locatTimezone | utc | result + ${value} | ${'UTC'} | ${undefined} | ${utcResult} + ${value} | ${'UTC'} | ${false} | ${utcResult} + ${value} | ${'UTC'} | ${true} | ${utcResult} + ${value} | ${'US/Pacific'} | ${undefined} | ${localResult} + ${value} | ${'US/Pacific'} | ${false} | ${localResult} + ${value} | ${'US/Pacific'} | ${true} | ${utcResult} + `( + 'when timezone is $locatTimezone, formats $result for utc = $utc', + ({ val, locatTimezone, utc, result }) => { + timezoneMock.register(locatTimezone); + + expect(inputStringToIsoDate(val, utc)).toBe(result); + + timezoneMock.unregister(); + }, + ); + }); }); - describe('isDateTimePickerInputValid', () => { + describe('isoDateToInputString', () => { [ { - input: null, - output: false, - }, - { - input: '', - output: false, + input: '2019-09-08T01:01:01Z', + output: '2019-09-08 01:01:01', }, { - input: 'xxxx-xx-xx', - output: false, + input: '2019-09-08T01:01:01.999Z', + output: '2019-09-08 01:01:01', }, { - input: '9999-99-19', - output: false, - }, - { - input: '2019-19-23', - output: false, - }, - { - input: '2019-09-23', - output: true, - }, - { - input: '2019-09-23 x', - output: false, - }, - { - input: '2019-09-29 0:0:0', - output: false, - }, - { - input: '2019-09-29 00:00:00', - output: true, - }, - { - input: '2019-09-29 24:24:24', - output: false, - }, - { - input: '2019-09-29 23:24:24', - output: true, - }, - { - input: '2019-09-29 23:24:24 ', - output: false, + input: '2019-09-08T00:00:00Z', + output: '2019-09-08 00:00:00', }, ].forEach(({ input, output }) => { it(`returns ${output} for ${input}`, () => { - expect(dateTimePickerLib.isDateTimePickerInputValid(input)).toBe(output); + expect(isoDateToInputString(input)).toBe(output); }); }); + + describe('timezone formatting', () => { + const value = '2019-09-08T08:01:01Z'; + const utcResult = '2019-09-08 08:01:01'; + const localResult = '2019-09-08 01:01:01'; + + test.each` + val | locatTimezone | utc | result + ${value} | ${'UTC'} | ${undefined} | ${utcResult} + ${value} | ${'UTC'} | ${false} | ${utcResult} + ${value} | ${'UTC'} | ${true} | ${utcResult} + ${value} | ${'US/Pacific'} | ${undefined} | ${localResult} + ${value} | ${'US/Pacific'} | ${false} | ${localResult} + ${value} | ${'US/Pacific'} | ${true} | ${utcResult} + `( + 'when timezone is $locatTimezone, formats $result for utc = $utc', + ({ val, locatTimezone, utc, result }) => { + timezoneMock.register(locatTimezone); + + expect(isoDateToInputString(val, utc)).toBe(result); + + timezoneMock.unregister(); + }, + ); + }); }); }); diff --git a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js index 90130917d8f..ceea8d2fa92 100644 --- a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js +++ b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import timezoneMock from 'timezone-mock'; import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; import { defaultTimeRanges, @@ -8,16 +9,16 @@ import { const optionsCount = defaultTimeRanges.length; describe('DateTimePicker', () => { - let dateTimePicker; + let wrapper; - const dropdownToggle = () => dateTimePicker.find('.dropdown-toggle'); - const dropdownMenu = () => dateTimePicker.find('.dropdown-menu'); - const applyButtonElement = () => dateTimePicker.find('button.btn-success').element; - const findQuickRangeItems = () => dateTimePicker.findAll('.dropdown-item'); - const cancelButtonElement = () => dateTimePicker.find('button.btn-secondary').element; + const dropdownToggle = () => wrapper.find('.dropdown-toggle'); + const dropdownMenu = () => wrapper.find('.dropdown-menu'); + const applyButtonElement = () => wrapper.find('button.btn-success').element; + const findQuickRangeItems = () => wrapper.findAll('.dropdown-item'); + const cancelButtonElement = () => wrapper.find('button.btn-secondary').element; const createComponent = props => { - dateTimePicker = mount(DateTimePicker, { + wrapper = mount(DateTimePicker, { propsData: { ...props, }, @@ -25,54 +26,86 @@ describe('DateTimePicker', () => { }; afterEach(() => { - dateTimePicker.destroy(); + wrapper.destroy(); }); - it('renders dropdown toggle button with selected text', done => { + it('renders dropdown toggle button with selected text', () => { createComponent(); - dateTimePicker.vm.$nextTick(() => { + return wrapper.vm.$nextTick(() => { expect(dropdownToggle().text()).toBe(defaultTimeRange.label); - done(); + }); + }); + + it('renders dropdown toggle button with selected text and utc label', () => { + createComponent({ utc: true }); + return wrapper.vm.$nextTick(() => { + expect(dropdownToggle().text()).toContain(defaultTimeRange.label); + expect(dropdownToggle().text()).toContain('UTC'); }); }); it('renders dropdown with 2 custom time range inputs', () => { createComponent(); - dateTimePicker.vm.$nextTick(() => { - expect(dateTimePicker.findAll('input').length).toBe(2); + return wrapper.vm.$nextTick(() => { + expect(wrapper.findAll('input').length).toBe(2); }); }); - it('renders inputs with h/m/s truncated if its all 0s', done => { - createComponent({ - value: { + describe('renders label with h/m/s truncated if possible', () => { + [ + { + start: '2019-10-10T00:00:00.000Z', + end: '2019-10-10T00:00:00.000Z', + label: '2019-10-10 to 2019-10-10', + }, + { start: '2019-10-10T00:00:00.000Z', end: '2019-10-14T00:10:00.000Z', + label: '2019-10-10 to 2019-10-14 00:10:00', }, - }); - dateTimePicker.vm.$nextTick(() => { - expect(dateTimePicker.find('#custom-time-from').element.value).toBe('2019-10-10'); - expect(dateTimePicker.find('#custom-time-to').element.value).toBe('2019-10-14 00:10:00'); - done(); + { + start: '2019-10-10T00:00:00.000Z', + end: '2019-10-10T00:00:01.000Z', + label: '2019-10-10 to 2019-10-10 00:00:01', + }, + { + start: '2019-10-10T00:00:01.000Z', + end: '2019-10-10T00:00:01.000Z', + label: '2019-10-10 00:00:01 to 2019-10-10 00:00:01', + }, + { + start: '2019-10-10T00:00:01.000Z', + end: '2019-10-10T00:00:01.000Z', + utc: true, + label: '2019-10-10 00:00:01 to 2019-10-10 00:00:01 UTC', + }, + ].forEach(({ start, end, utc, label }) => { + it(`for start ${start}, end ${end}, and utc ${utc}, label is ${label}`, () => { + createComponent({ + value: { start, end }, + utc, + }); + return wrapper.vm.$nextTick(() => { + expect(dropdownToggle().text()).toBe(label); + }); + }); }); }); - it(`renders dropdown with ${optionsCount} (default) items in quick range`, done => { + it(`renders dropdown with ${optionsCount} (default) items in quick range`, () => { createComponent(); dropdownToggle().trigger('click'); - dateTimePicker.vm.$nextTick(() => { + return wrapper.vm.$nextTick(() => { expect(findQuickRangeItems().length).toBe(optionsCount); - done(); }); }); - it('renders dropdown with a default quick range item selected', done => { + it('renders dropdown with a default quick range item selected', () => { createComponent(); dropdownToggle().trigger('click'); - dateTimePicker.vm.$nextTick(() => { - expect(dateTimePicker.find('.dropdown-item.active').exists()).toBe(true); - expect(dateTimePicker.find('.dropdown-item.active').text()).toBe(defaultTimeRange.label); - done(); + return wrapper.vm.$nextTick(() => { + expect(wrapper.find('.dropdown-item.active').exists()).toBe(true); + expect(wrapper.find('.dropdown-item.active').text()).toBe(defaultTimeRange.label); }); }); @@ -86,78 +119,128 @@ describe('DateTimePicker', () => { describe('user input', () => { const fillInputAndBlur = (input, val) => { - dateTimePicker.find(input).setValue(val); - return dateTimePicker.vm.$nextTick().then(() => { - dateTimePicker.find(input).trigger('blur'); - return dateTimePicker.vm.$nextTick(); + wrapper.find(input).setValue(val); + return wrapper.vm.$nextTick().then(() => { + wrapper.find(input).trigger('blur'); + return wrapper.vm.$nextTick(); }); }; - beforeEach(done => { + beforeEach(() => { createComponent(); - dateTimePicker.vm.$nextTick(done); + return wrapper.vm.$nextTick(); }); - it('displays inline error message if custom time range inputs are invalid', done => { - fillInputAndBlur('#custom-time-from', '2019-10-01abc') + it('displays inline error message if custom time range inputs are invalid', () => { + return fillInputAndBlur('#custom-time-from', '2019-10-01abc') .then(() => fillInputAndBlur('#custom-time-to', '2019-10-10abc')) .then(() => { - expect(dateTimePicker.findAll('.invalid-feedback').length).toBe(2); - done(); - }) - .catch(done); + expect(wrapper.findAll('.invalid-feedback').length).toBe(2); + }); }); - it('keeps apply button disabled with invalid custom time range inputs', done => { - fillInputAndBlur('#custom-time-from', '2019-10-01abc') + it('keeps apply button disabled with invalid custom time range inputs', () => { + return fillInputAndBlur('#custom-time-from', '2019-10-01abc') .then(() => fillInputAndBlur('#custom-time-to', '2019-09-19')) .then(() => { expect(applyButtonElement().getAttribute('disabled')).toBe('disabled'); - done(); - }) - .catch(done); + }); }); - it('enables apply button with valid custom time range inputs', done => { - fillInputAndBlur('#custom-time-from', '2019-10-01') + it('enables apply button with valid custom time range inputs', () => { + return fillInputAndBlur('#custom-time-from', '2019-10-01') .then(() => fillInputAndBlur('#custom-time-to', '2019-10-19')) .then(() => { expect(applyButtonElement().getAttribute('disabled')).toBeNull(); - done(); - }) - .catch(done.fail); + }); }); - it('emits dates in an object when apply is clicked', done => { - fillInputAndBlur('#custom-time-from', '2019-10-01') - .then(() => fillInputAndBlur('#custom-time-to', '2019-10-19')) - .then(() => { - applyButtonElement().click(); - - expect(dateTimePicker.emitted().input).toHaveLength(1); - expect(dateTimePicker.emitted().input[0]).toEqual([ - { - end: '2019-10-19T00:00:00Z', - start: '2019-10-01T00:00:00Z', - }, - ]); - done(); - }) - .catch(done.fail); + describe('when "apply" is clicked', () => { + it('emits iso dates', () => { + return fillInputAndBlur('#custom-time-from', '2019-10-01 00:00:00') + .then(() => fillInputAndBlur('#custom-time-to', '2019-10-19 00:00:00')) + .then(() => { + applyButtonElement().click(); + + expect(wrapper.emitted().input).toHaveLength(1); + expect(wrapper.emitted().input[0]).toEqual([ + { + end: '2019-10-19T00:00:00Z', + start: '2019-10-01T00:00:00Z', + }, + ]); + }); + }); + + it('emits iso dates, for dates without time of day', () => { + return fillInputAndBlur('#custom-time-from', '2019-10-01') + .then(() => fillInputAndBlur('#custom-time-to', '2019-10-19')) + .then(() => { + applyButtonElement().click(); + + expect(wrapper.emitted().input).toHaveLength(1); + expect(wrapper.emitted().input[0]).toEqual([ + { + end: '2019-10-19T00:00:00Z', + start: '2019-10-01T00:00:00Z', + }, + ]); + }); + }); + + describe('when timezone is different', () => { + beforeAll(() => { + timezoneMock.register('US/Pacific'); + }); + afterAll(() => { + timezoneMock.unregister(); + }); + + it('emits iso dates', () => { + return fillInputAndBlur('#custom-time-from', '2019-10-01 00:00:00') + .then(() => fillInputAndBlur('#custom-time-to', '2019-10-19 12:00:00')) + .then(() => { + applyButtonElement().click(); + + expect(wrapper.emitted().input).toHaveLength(1); + expect(wrapper.emitted().input[0]).toEqual([ + { + start: '2019-10-01T07:00:00Z', + end: '2019-10-19T19:00:00Z', + }, + ]); + }); + }); + + it('emits iso dates with utc format', () => { + wrapper.setProps({ utc: true }); + return wrapper.vm + .$nextTick() + .then(() => fillInputAndBlur('#custom-time-from', '2019-10-01 00:00:00')) + .then(() => fillInputAndBlur('#custom-time-to', '2019-10-19 12:00:00')) + .then(() => { + applyButtonElement().click(); + + expect(wrapper.emitted().input).toHaveLength(1); + expect(wrapper.emitted().input[0]).toEqual([ + { + start: '2019-10-01T00:00:00Z', + end: '2019-10-19T12:00:00Z', + }, + ]); + }); + }); + }); }); - it('unchecks quick range when text is input is clicked', done => { + it('unchecks quick range when text is input is clicked', () => { const findActiveItems = () => findQuickRangeItems().filter(w => w.is('.active')); expect(findActiveItems().length).toBe(1); - fillInputAndBlur('#custom-time-from', '2019-10-01') - .then(() => { - expect(findActiveItems().length).toBe(0); - - done(); - }) - .catch(done.fail); + return fillInputAndBlur('#custom-time-from', '2019-10-01').then(() => { + expect(findActiveItems().length).toBe(0); + }); }); it('emits dates in an object when a is clicked', () => { @@ -165,23 +248,22 @@ describe('DateTimePicker', () => { .at(3) // any item .trigger('click'); - expect(dateTimePicker.emitted().input).toHaveLength(1); - expect(dateTimePicker.emitted().input[0][0]).toMatchObject({ + expect(wrapper.emitted().input).toHaveLength(1); + expect(wrapper.emitted().input[0][0]).toMatchObject({ duration: { seconds: expect.any(Number), }, }); }); - it('hides the popover with cancel button', done => { + it('hides the popover with cancel button', () => { dropdownToggle().trigger('click'); - dateTimePicker.vm.$nextTick(() => { + return wrapper.vm.$nextTick(() => { cancelButtonElement().click(); - dateTimePicker.vm.$nextTick(() => { + return wrapper.vm.$nextTick(() => { expect(dropdownMenu().classes('show')).toBe(false); - done(); }); }); }); @@ -210,7 +292,7 @@ describe('DateTimePicker', () => { jest.spyOn(Date, 'now').mockImplementation(() => MOCK_NOW); }); - it('renders dropdown with a label in the quick range', done => { + it('renders dropdown with a label in the quick range', () => { createComponent({ value: { duration: { seconds: 60 * 5 }, @@ -218,14 +300,26 @@ describe('DateTimePicker', () => { options: otherTimeRanges, }); dropdownToggle().trigger('click'); - dateTimePicker.vm.$nextTick(() => { + return wrapper.vm.$nextTick(() => { expect(dropdownToggle().text()).toBe('5 minutes'); + }); + }); - done(); + it('renders dropdown with a label in the quick range and utc label', () => { + createComponent({ + value: { + duration: { seconds: 60 * 5 }, + }, + utc: true, + options: otherTimeRanges, + }); + dropdownToggle().trigger('click'); + return wrapper.vm.$nextTick(() => { + expect(dropdownToggle().text()).toBe('5 minutes UTC'); }); }); - it('renders dropdown with quick range items', done => { + it('renders dropdown with quick range items', () => { createComponent({ value: { duration: { seconds: 60 * 2 }, @@ -233,7 +327,7 @@ describe('DateTimePicker', () => { options: otherTimeRanges, }); dropdownToggle().trigger('click'); - dateTimePicker.vm.$nextTick(() => { + return wrapper.vm.$nextTick(() => { const items = findQuickRangeItems(); expect(items.length).toBe(Object.keys(otherTimeRanges).length); @@ -245,22 +339,18 @@ describe('DateTimePicker', () => { expect(items.at(2).text()).toBe('5 minutes'); expect(items.at(2).is('.active')).toBe(false); - - done(); }); }); - it('renders dropdown with a label not in the quick range', done => { + it('renders dropdown with a label not in the quick range', () => { createComponent({ value: { duration: { seconds: 60 * 4 }, }, }); dropdownToggle().trigger('click'); - dateTimePicker.vm.$nextTick(() => { + return wrapper.vm.$nextTick(() => { expect(dropdownToggle().text()).toBe('2020-01-23 19:56:00 to 2020-01-23 20:00:00'); - - done(); }); }); }); diff --git a/spec/frontend/vue_shared/components/deprecated_modal_2_spec.js b/spec/frontend/vue_shared/components/deprecated_modal_2_spec.js new file mode 100644 index 00000000000..b201a9acdd4 --- /dev/null +++ b/spec/frontend/vue_shared/components/deprecated_modal_2_spec.js @@ -0,0 +1,258 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; + +const modalComponent = Vue.extend(DeprecatedModal2); + +describe('DeprecatedModal2', () => { + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + describe('props', () => { + describe('with id', () => { + const props = { + id: 'my-modal', + }; + + beforeEach(() => { + vm = mountComponent(modalComponent, props); + }); + + it('assigns the id to the modal', () => { + expect(vm.$el.id).toBe(props.id); + }); + }); + + describe('without id', () => { + beforeEach(() => { + vm = mountComponent(modalComponent, {}); + }); + + it('does not add an id attribute to the modal', () => { + expect(vm.$el.hasAttribute('id')).toBe(false); + }); + }); + + describe('with headerTitleText', () => { + const props = { + headerTitleText: 'my title text', + }; + + beforeEach(() => { + vm = mountComponent(modalComponent, props); + }); + + it('sets the modal title', () => { + const modalTitle = vm.$el.querySelector('.modal-title'); + + expect(modalTitle.innerHTML.trim()).toBe(props.headerTitleText); + }); + }); + + describe('with footerPrimaryButtonVariant', () => { + const props = { + footerPrimaryButtonVariant: 'danger', + }; + + beforeEach(() => { + vm = mountComponent(modalComponent, props); + }); + + it('sets the primary button class', () => { + const primaryButton = vm.$el.querySelector('.modal-footer button:last-of-type'); + + expect(primaryButton).toHaveClass(`btn-${props.footerPrimaryButtonVariant}`); + }); + }); + + describe('with footerPrimaryButtonText', () => { + const props = { + footerPrimaryButtonText: 'my button text', + }; + + beforeEach(() => { + vm = mountComponent(modalComponent, props); + }); + + it('sets the primary button text', () => { + const primaryButton = vm.$el.querySelector('.modal-footer button:last-of-type'); + + expect(primaryButton.innerHTML.trim()).toBe(props.footerPrimaryButtonText); + }); + }); + }); + + it('works with data-toggle="modal"', () => { + setFixtures(` + <button id="modal-button" data-toggle="modal" data-target="#my-modal"></button> + <div id="modal-container"></div> + `); + + const modalContainer = document.getElementById('modal-container'); + const modalButton = document.getElementById('modal-button'); + vm = mountComponent( + modalComponent, + { + id: 'my-modal', + }, + modalContainer, + ); + const modalElement = document.getElementById('my-modal'); + + modalButton.click(); + + expect(modalElement).not.toHaveClass('show'); + + // let the modal fade in + jest.runOnlyPendingTimers(); + + expect(modalElement).toHaveClass('show'); + }); + + describe('methods', () => { + const dummyEvent = 'not really an event'; + + beforeEach(() => { + vm = mountComponent(modalComponent, {}); + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + }); + + describe('emitCancel', () => { + it('emits a cancel event', () => { + vm.emitCancel(dummyEvent); + + expect(vm.$emit).toHaveBeenCalledWith('cancel', dummyEvent); + }); + }); + + describe('emitSubmit', () => { + it('emits a submit event', () => { + vm.emitSubmit(dummyEvent); + + expect(vm.$emit).toHaveBeenCalledWith('submit', dummyEvent); + }); + }); + + describe('opened', () => { + it('emits a open event', () => { + vm.opened(); + + expect(vm.$emit).toHaveBeenCalledWith('open'); + }); + }); + + describe('closed', () => { + it('emits a closed event', () => { + vm.closed(); + + expect(vm.$emit).toHaveBeenCalledWith('closed'); + }); + }); + }); + + describe('slots', () => { + const slotContent = 'this should go into the slot'; + + const modalWithSlot = slot => { + return Vue.extend({ + components: { + DeprecatedModal2, + }, + render: h => + h('deprecated-modal-2', [slot ? h('template', { slot }, slotContent) : slotContent]), + }); + }; + + describe('default slot', () => { + beforeEach(() => { + vm = mountComponent(modalWithSlot()); + }); + + it('sets the modal body', () => { + const modalBody = vm.$el.querySelector('.modal-body'); + + expect(modalBody.innerHTML).toBe(slotContent); + }); + }); + + describe('header slot', () => { + beforeEach(() => { + vm = mountComponent(modalWithSlot('header')); + }); + + it('sets the modal header', () => { + const modalHeader = vm.$el.querySelector('.modal-header'); + + expect(modalHeader.innerHTML).toBe(slotContent); + }); + }); + + describe('title slot', () => { + beforeEach(() => { + vm = mountComponent(modalWithSlot('title')); + }); + + it('sets the modal title', () => { + const modalTitle = vm.$el.querySelector('.modal-title'); + + expect(modalTitle.innerHTML).toBe(slotContent); + }); + }); + + describe('footer slot', () => { + beforeEach(() => { + vm = mountComponent(modalWithSlot('footer')); + }); + + it('sets the modal footer', () => { + const modalFooter = vm.$el.querySelector('.modal-footer'); + + expect(modalFooter.innerHTML).toBe(slotContent); + }); + }); + }); + + describe('handling sizes', () => { + it('should render modal-sm', () => { + vm = mountComponent(modalComponent, { + modalSize: 'sm', + }); + + expect(vm.$el.querySelector('.modal-dialog').classList.contains('modal-sm')).toEqual(true); + }); + + it('should render modal-lg', () => { + vm = mountComponent(modalComponent, { + modalSize: 'lg', + }); + + expect(vm.$el.querySelector('.modal-dialog').classList.contains('modal-lg')).toEqual(true); + }); + + it('should render modal-xl', () => { + vm = mountComponent(modalComponent, { + modalSize: 'xl', + }); + + expect(vm.$el.querySelector('.modal-dialog').classList.contains('modal-xl')).toEqual(true); + }); + + it('should not add modal size classes when md size is passed', () => { + vm = mountComponent(modalComponent, { + modalSize: 'md', + }); + + expect(vm.$el.querySelector('.modal-dialog').classList.contains('modal-md')).toEqual(false); + }); + + it('should not add modal size classes by default', () => { + vm = mountComponent(modalComponent, {}); + + expect(vm.$el.querySelector('.modal-dialog').classList.contains('modal-sm')).toEqual(false); + expect(vm.$el.querySelector('.modal-dialog').classList.contains('modal-lg')).toEqual(false); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/deprecated_modal_spec.js b/spec/frontend/vue_shared/components/deprecated_modal_spec.js new file mode 100644 index 00000000000..b9793ce2d80 --- /dev/null +++ b/spec/frontend/vue_shared/components/deprecated_modal_spec.js @@ -0,0 +1,73 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; + +const modalComponent = Vue.extend(DeprecatedModal); + +describe('DeprecatedModal', () => { + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + describe('props', () => { + describe('without primaryButtonLabel', () => { + beforeEach(() => { + vm = mountComponent(modalComponent, { + primaryButtonLabel: null, + }); + }); + + it('does not render a primary button', () => { + expect(vm.$el.querySelector('.js-primary-button')).toBeNull(); + }); + }); + + describe('with id', () => { + describe('does not render a primary button', () => { + beforeEach(() => { + vm = mountComponent(modalComponent, { + id: 'my-modal', + }); + }); + + it('assigns the id to the modal', () => { + expect(vm.$el.querySelector('#my-modal.modal')).not.toBeNull(); + }); + + it('does not show the modal immediately', () => { + expect(vm.$el.querySelector('#my-modal.modal')).not.toHaveClass('show'); + }); + + it('does not show a backdrop', () => { + expect(vm.$el.querySelector('modal-backdrop')).toBeNull(); + }); + }); + }); + + it('works with data-toggle="modal"', () => { + setFixtures(` + <button id="modal-button" data-toggle="modal" data-target="#my-modal"></button> + <div id="modal-container"></div> + `); + + const modalContainer = document.getElementById('modal-container'); + const modalButton = document.getElementById('modal-button'); + vm = mountComponent( + modalComponent, + { + id: 'my-modal', + }, + modalContainer, + ); + const modalElement = vm.$el.querySelector('#my-modal'); + + expect(modalElement).not.toHaveClass('show'); + + modalButton.click(); + + expect(modalElement).toHaveClass('show'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js b/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js index 636508be6b6..a6e4d812c3c 100644 --- a/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js +++ b/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js @@ -8,6 +8,7 @@ describe('DiffViewer', () => { const requiredProps = { diffMode: 'replaced', diffViewerMode: 'image', + diffFile: {}, newPath: GREEN_BOX_IMAGE_URL, newSha: 'ABC', oldPath: RED_BOX_IMAGE_URL, @@ -71,16 +72,27 @@ describe('DiffViewer', () => { }); }); - it('renders renamed component', () => { - createComponent({ - ...requiredProps, - diffMode: 'renamed', - diffViewerMode: 'renamed', - newPath: 'test.abc', - oldPath: 'testold.abc', + describe('renamed file', () => { + it.each` + altViewer + ${'text'} + ${'notText'} + `('renders the renamed component when the alternate viewer is $altViewer', ({ altViewer }) => { + createComponent({ + ...requiredProps, + diffFile: { + content_sha: '', + view_path: '', + alternate_viewer: { name: altViewer }, + }, + diffMode: 'renamed', + diffViewerMode: 'renamed', + newPath: 'test.abc', + oldPath: 'testold.abc', + }); + + expect(vm.$el.textContent).toContain('File renamed with no changes.'); }); - - expect(vm.$el.textContent).toContain('File moved'); }); it('renders mode changed component', () => { diff --git a/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js b/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js new file mode 100644 index 00000000000..e0e982f4e11 --- /dev/null +++ b/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js @@ -0,0 +1,283 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; +import Renamed from '~/vue_shared/components/diff_viewer/viewers/renamed.vue'; +import { + TRANSITION_LOAD_START, + TRANSITION_LOAD_ERROR, + TRANSITION_LOAD_SUCCEED, + TRANSITION_ACKNOWLEDGE_ERROR, + STATE_IDLING, + STATE_LOADING, + STATE_ERRORED, +} from '~/diffs/constants'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +function createRenamedComponent({ + props = {}, + methods = {}, + store = new Vuex.Store({}), + deep = false, +}) { + const mnt = deep ? mount : shallowMount; + + return mnt(Renamed, { + propsData: { ...props }, + localVue, + store, + methods, + }); +} + +describe('Renamed Diff Viewer', () => { + const DIFF_FILE_COMMIT_SHA = 'commitsha'; + const DIFF_FILE_SHORT_SHA = 'commitsh'; + const DIFF_FILE_VIEW_PATH = `blob/${DIFF_FILE_COMMIT_SHA}/filename.ext`; + let diffFile; + let wrapper; + + beforeEach(() => { + diffFile = { + content_sha: DIFF_FILE_COMMIT_SHA, + view_path: DIFF_FILE_VIEW_PATH, + alternate_viewer: { + name: 'text', + }, + }; + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('is', () => { + beforeEach(() => { + wrapper = createRenamedComponent({ props: { diffFile } }); + }); + + it.each` + state | request | result + ${'idle'} | ${'idle'} | ${true} + ${'idle'} | ${'loading'} | ${false} + ${'idle'} | ${'errored'} | ${false} + ${'loading'} | ${'loading'} | ${true} + ${'loading'} | ${'idle'} | ${false} + ${'loading'} | ${'errored'} | ${false} + ${'errored'} | ${'errored'} | ${true} + ${'errored'} | ${'idle'} | ${false} + ${'errored'} | ${'loading'} | ${false} + `( + 'returns the $result for "$request" when the state is "$state"', + ({ request, result, state }) => { + wrapper.vm.state = state; + + expect(wrapper.vm.is(request)).toEqual(result); + }, + ); + }); + + describe('transition', () => { + beforeEach(() => { + wrapper = createRenamedComponent({ props: { diffFile } }); + }); + + it.each` + state | transition | result + ${'idle'} | ${TRANSITION_LOAD_START} | ${STATE_LOADING} + ${'idle'} | ${TRANSITION_LOAD_ERROR} | ${STATE_IDLING} + ${'idle'} | ${TRANSITION_LOAD_SUCCEED} | ${STATE_IDLING} + ${'idle'} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_IDLING} + ${'loading'} | ${TRANSITION_LOAD_START} | ${STATE_LOADING} + ${'loading'} | ${TRANSITION_LOAD_ERROR} | ${STATE_ERRORED} + ${'loading'} | ${TRANSITION_LOAD_SUCCEED} | ${STATE_IDLING} + ${'loading'} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_LOADING} + ${'errored'} | ${TRANSITION_LOAD_START} | ${STATE_LOADING} + ${'errored'} | ${TRANSITION_LOAD_ERROR} | ${STATE_ERRORED} + ${'errored'} | ${TRANSITION_LOAD_SUCCEED} | ${STATE_ERRORED} + ${'errored'} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_IDLING} + `( + 'correctly updates the state to "$result" when it starts as "$state" and the transition is "$transition"', + ({ state, transition, result }) => { + wrapper.vm.state = state; + + wrapper.vm.transition(transition); + + expect(wrapper.vm.state).toEqual(result); + }, + ); + }); + + describe('switchToFull', () => { + let store; + + beforeEach(() => { + store = new Vuex.Store({ + modules: { + diffs: { + namespaced: true, + actions: { switchToFullDiffFromRenamedFile: () => {} }, + }, + }, + }); + + jest.spyOn(store, 'dispatch'); + + wrapper = createRenamedComponent({ props: { diffFile }, store }); + }); + + afterEach(() => { + store = null; + }); + + it('calls the switchToFullDiffFromRenamedFile action when the method is triggered', () => { + store.dispatch.mockResolvedValue(); + + wrapper.vm.switchToFull(); + + return wrapper.vm.$nextTick().then(() => { + expect(store.dispatch).toHaveBeenCalledWith('diffs/switchToFullDiffFromRenamedFile', { + diffFile, + }); + }); + }); + + it.each` + after | resolvePromise | resolution + ${STATE_IDLING} | ${'mockResolvedValue'} | ${'successful'} + ${STATE_ERRORED} | ${'mockRejectedValue'} | ${'rejected'} + `( + 'moves through the correct states during a $resolution request', + ({ after, resolvePromise }) => { + store.dispatch[resolvePromise](); + + expect(wrapper.vm.state).toEqual(STATE_IDLING); + + wrapper.vm.switchToFull(); + + expect(wrapper.vm.state).toEqual(STATE_LOADING); + + return ( + wrapper.vm + // This tick is needed for when the action (promise) finishes + .$nextTick() + // This tick waits for the state change in the promise .then/.catch to bubble into the component + .then(() => wrapper.vm.$nextTick()) + .then(() => { + expect(wrapper.vm.state).toEqual(after); + }) + ); + }, + ); + }); + + describe('clickLink', () => { + let event; + + beforeEach(() => { + event = { + preventDefault: jest.fn(), + }; + }); + + it.each` + alternateViewer | stops | handled + ${'text'} | ${true} | ${'should'} + ${'nottext'} | ${false} | ${'should not'} + `( + 'given { alternate_viewer: { name: "$alternateViewer" } }, the click event $handled be handled in the component', + ({ alternateViewer, stops }) => { + wrapper = createRenamedComponent({ + props: { + diffFile: { + ...diffFile, + alternate_viewer: { name: alternateViewer }, + }, + }, + }); + + jest.spyOn(wrapper.vm, 'switchToFull').mockImplementation(() => {}); + + wrapper.vm.clickLink(event); + + if (stops) { + expect(event.preventDefault).toHaveBeenCalled(); + expect(wrapper.vm.switchToFull).toHaveBeenCalled(); + } else { + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(wrapper.vm.switchToFull).not.toHaveBeenCalled(); + } + }, + ); + }); + + describe('dismissError', () => { + let transitionSpy; + + beforeEach(() => { + wrapper = createRenamedComponent({ props: { diffFile } }); + transitionSpy = jest.spyOn(wrapper.vm, 'transition'); + }); + + it(`transitions the component with "${TRANSITION_ACKNOWLEDGE_ERROR}"`, () => { + wrapper.vm.dismissError(); + + expect(transitionSpy).toHaveBeenCalledWith(TRANSITION_ACKNOWLEDGE_ERROR); + }); + }); + + describe('output', () => { + it.each` + altViewer | nameDisplay + ${'text'} | ${'"text"'} + ${'nottext'} | ${'"nottext"'} + ${undefined} | ${undefined} + ${null} | ${null} + `( + 'with { alternate_viewer: { name: $nameDisplay } }, renders the component', + ({ altViewer }) => { + const file = { ...diffFile }; + + file.alternate_viewer.name = altViewer; + wrapper = createRenamedComponent({ props: { diffFile: file } }); + + expect(wrapper.find('[test-id="plaintext"]').text()).toEqual( + 'File renamed with no changes.', + ); + }, + ); + + it.each` + altType | linkText + ${'text'} | ${'Show file contents'} + ${'nottext'} | ${`View file @ ${DIFF_FILE_SHORT_SHA}`} + `( + 'includes a link to the full file for alternate viewer type "$altType"', + ({ altType, linkText }) => { + const file = { ...diffFile }; + const clickMock = jest.fn().mockImplementation(() => {}); + + file.alternate_viewer.name = altType; + wrapper = createRenamedComponent({ + deep: true, + props: { diffFile: file }, + methods: { + clickLink: clickMock, + }, + }); + + const link = wrapper.find('a'); + + expect(link.text()).toEqual(linkText); + expect(link.attributes('href')).toEqual(DIFF_FILE_VIEW_PATH); + + link.vm.$emit('click'); + + expect(clickMock).toHaveBeenCalled(); + }, + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/file_finder/index_spec.js b/spec/frontend/vue_shared/components/file_finder/index_spec.js new file mode 100644 index 00000000000..f9e56774526 --- /dev/null +++ b/spec/frontend/vue_shared/components/file_finder/index_spec.js @@ -0,0 +1,368 @@ +import Vue from 'vue'; +import Mousetrap from 'mousetrap'; +import { file } from 'jest/ide/helpers'; +import waitForPromises from 'helpers/wait_for_promises'; +import FindFileComponent from '~/vue_shared/components/file_finder/index.vue'; +import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; + +describe('File finder item spec', () => { + const Component = Vue.extend(FindFileComponent); + let vm; + + function createComponent(props) { + vm = new Component({ + propsData: { + files: [], + visible: true, + loading: false, + ...props, + }, + }); + + vm.$mount('#app'); + } + + beforeEach(() => { + setFixtures('<div id="app"></div>'); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('with entries', () => { + beforeEach(done => { + createComponent({ + files: [ + { + ...file('index.js'), + path: 'index.js', + type: 'blob', + url: '/index.jsurl', + }, + { + ...file('component.js'), + path: 'component.js', + type: 'blob', + }, + ], + }); + + setImmediate(done); + }); + + it('renders list of blobs', () => { + expect(vm.$el.textContent).toContain('index.js'); + expect(vm.$el.textContent).toContain('component.js'); + expect(vm.$el.textContent).not.toContain('folder'); + }); + + it('filters entries', done => { + vm.searchText = 'index'; + + setImmediate(() => { + expect(vm.$el.textContent).toContain('index.js'); + expect(vm.$el.textContent).not.toContain('component.js'); + + done(); + }); + }); + + it('shows clear button when searchText is not empty', done => { + vm.searchText = 'index'; + + setImmediate(() => { + expect(vm.$el.querySelector('.dropdown-input').classList).toContain('has-value'); + expect(vm.$el.querySelector('.dropdown-input-search').classList).toContain('hidden'); + + done(); + }); + }); + + it('clear button resets searchText', done => { + vm.searchText = 'index'; + + waitForPromises() + .then(() => { + vm.$el.querySelector('.dropdown-input-clear').click(); + }) + .then(waitForPromises) + .then(() => { + expect(vm.searchText).toBe(''); + }) + .then(done) + .catch(done.fail); + }); + + it('clear button focues search input', done => { + jest.spyOn(vm.$refs.searchInput, 'focus').mockImplementation(() => {}); + vm.searchText = 'index'; + + waitForPromises() + .then(() => { + vm.$el.querySelector('.dropdown-input-clear').click(); + }) + .then(waitForPromises) + .then(() => { + expect(vm.$refs.searchInput.focus).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + + describe('listShowCount', () => { + it('returns 1 when no filtered entries exist', done => { + vm.searchText = 'testing 123'; + + setImmediate(() => { + expect(vm.listShowCount).toBe(1); + + done(); + }); + }); + + it('returns entries length when not filtered', () => { + expect(vm.listShowCount).toBe(2); + }); + }); + + describe('listHeight', () => { + it('returns 55 when entries exist', () => { + expect(vm.listHeight).toBe(55); + }); + + it('returns 33 when entries dont exist', done => { + vm.searchText = 'testing 123'; + + setImmediate(() => { + expect(vm.listHeight).toBe(33); + + done(); + }); + }); + }); + + describe('filteredBlobsLength', () => { + it('returns length of filtered blobs', done => { + vm.searchText = 'index'; + + setImmediate(() => { + expect(vm.filteredBlobsLength).toBe(1); + + done(); + }); + }); + }); + + describe('watches', () => { + describe('searchText', () => { + it('resets focusedIndex when updated', done => { + vm.focusedIndex = 1; + vm.searchText = 'test'; + + setImmediate(() => { + expect(vm.focusedIndex).toBe(0); + + done(); + }); + }); + }); + + describe('visible', () => { + it('returns searchText when false', done => { + vm.searchText = 'test'; + vm.visible = true; + + waitForPromises() + .then(() => { + vm.visible = false; + }) + .then(waitForPromises) + .then(() => { + expect(vm.searchText).toBe(''); + }) + .then(done) + .catch(done.fail); + }); + }); + }); + + describe('openFile', () => { + beforeEach(() => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + }); + + it('closes file finder', () => { + vm.openFile(vm.files[0]); + + expect(vm.$emit).toHaveBeenCalledWith('toggle', false); + }); + + it('pushes to router', () => { + vm.openFile(vm.files[0]); + + expect(vm.$emit).toHaveBeenCalledWith('click', vm.files[0]); + }); + }); + + describe('onKeyup', () => { + it('opens file on enter key', done => { + const event = new CustomEvent('keyup'); + event.keyCode = ENTER_KEY_CODE; + + jest.spyOn(vm, 'openFile').mockImplementation(() => {}); + + vm.$refs.searchInput.dispatchEvent(event); + + setImmediate(() => { + expect(vm.openFile).toHaveBeenCalledWith(vm.files[0]); + + done(); + }); + }); + + it('closes file finder on esc key', done => { + const event = new CustomEvent('keyup'); + event.keyCode = ESC_KEY_CODE; + + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + + vm.$refs.searchInput.dispatchEvent(event); + + setImmediate(() => { + expect(vm.$emit).toHaveBeenCalledWith('toggle', false); + + done(); + }); + }); + }); + + describe('onKeyDown', () => { + let el; + + beforeEach(() => { + el = vm.$refs.searchInput; + }); + + describe('up key', () => { + const event = new CustomEvent('keydown'); + event.keyCode = UP_KEY_CODE; + + it('resets to last index when at top', () => { + el.dispatchEvent(event); + + expect(vm.focusedIndex).toBe(1); + }); + + it('minus 1 from focusedIndex', () => { + vm.focusedIndex = 1; + + el.dispatchEvent(event); + + expect(vm.focusedIndex).toBe(0); + }); + }); + + describe('down key', () => { + const event = new CustomEvent('keydown'); + event.keyCode = DOWN_KEY_CODE; + + it('resets to first index when at bottom', () => { + vm.focusedIndex = 1; + el.dispatchEvent(event); + + expect(vm.focusedIndex).toBe(0); + }); + + it('adds 1 to focusedIndex', () => { + el.dispatchEvent(event); + + expect(vm.focusedIndex).toBe(1); + }); + }); + }); + }); + + describe('without entries', () => { + it('renders loading text when loading', () => { + createComponent({ + loading: true, + }); + + expect(vm.$el.textContent).toContain('Loading...'); + }); + + it('renders no files text', () => { + createComponent(); + + expect(vm.$el.textContent).toContain('No files found.'); + }); + }); + + describe('keyboard shortcuts', () => { + beforeEach(done => { + createComponent(); + + jest.spyOn(vm, 'toggle').mockImplementation(() => {}); + + vm.$nextTick(done); + }); + + it('calls toggle on `t` key press', done => { + Mousetrap.trigger('t'); + + vm.$nextTick() + .then(() => { + expect(vm.toggle).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + + it('calls toggle on `command+p` key press', done => { + Mousetrap.trigger('command+p'); + + vm.$nextTick() + .then(() => { + expect(vm.toggle).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + + it('calls toggle on `ctrl+p` key press', done => { + Mousetrap.trigger('ctrl+p'); + + vm.$nextTick() + .then(() => { + expect(vm.toggle).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + + it('always allows `command+p` to trigger toggle', () => { + expect( + vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'command+p'), + ).toBe(false); + }); + + it('always allows `ctrl+p` to trigger toggle', () => { + expect( + vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'ctrl+p'), + ).toBe(false); + }); + + it('onlys handles `t` when focused in input-field', () => { + expect( + vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 't'), + ).toBe(true); + }); + + it('stops callback in monaco editor', () => { + setFixtures('<div class="inputarea"></div>'); + + expect(vm.mousetrapStopCallback(null, document.querySelector('.inputarea'), 't')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js new file mode 100644 index 00000000000..eded5b87abc --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js @@ -0,0 +1,259 @@ +import { shallowMount } from '@vue/test-utils'; +import { + GlFilteredSearch, + GlButtonGroup, + GlButton, + GlNewDropdown as GlDropdown, + GlNewDropdownItem as GlDropdownItem, +} from '@gitlab/ui'; + +import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { SortDirection } from '~/vue_shared/components/filtered_search_bar/constants'; + +import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; +import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; + +import { mockAvailableTokens, mockSortOptions } from './mock_data'; + +const createComponent = ({ + namespace = 'gitlab-org/gitlab-test', + recentSearchesStorageKey = 'requirements', + tokens = mockAvailableTokens, + sortOptions = mockSortOptions, + searchInputPlaceholder = 'Filter requirements', +} = {}) => + shallowMount(FilteredSearchBarRoot, { + propsData: { + namespace, + recentSearchesStorageKey, + tokens, + sortOptions, + searchInputPlaceholder, + }, + }); + +describe('FilteredSearchBarRoot', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('data', () => { + it('initializes `filterValue`, `selectedSortOption` and `selectedSortDirection` data props', () => { + expect(wrapper.vm.filterValue).toEqual([]); + expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[0].sortDirection.descending); + expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.descending); + }); + }); + + describe('computed', () => { + describe('tokenSymbols', () => { + it('returns array of map containing type and symbols from `tokens` prop', () => { + expect(wrapper.vm.tokenSymbols).toEqual({ author_username: '@' }); + }); + }); + + describe('sortDirectionIcon', () => { + it('returns string "sort-lowest" when `selectedSortDirection` is "ascending"', () => { + wrapper.setData({ + selectedSortDirection: SortDirection.ascending, + }); + + expect(wrapper.vm.sortDirectionIcon).toBe('sort-lowest'); + }); + + it('returns string "sort-highest" when `selectedSortDirection` is "descending"', () => { + wrapper.setData({ + selectedSortDirection: SortDirection.descending, + }); + + expect(wrapper.vm.sortDirectionIcon).toBe('sort-highest'); + }); + }); + + describe('sortDirectionTooltip', () => { + it('returns string "Sort direction: Ascending" when `selectedSortDirection` is "ascending"', () => { + wrapper.setData({ + selectedSortDirection: SortDirection.ascending, + }); + + expect(wrapper.vm.sortDirectionTooltip).toBe('Sort direction: Ascending'); + }); + + it('returns string "Sort direction: Descending" when `selectedSortDirection` is "descending"', () => { + wrapper.setData({ + selectedSortDirection: SortDirection.descending, + }); + + expect(wrapper.vm.sortDirectionTooltip).toBe('Sort direction: Descending'); + }); + }); + }); + + describe('watchers', () => { + describe('filterValue', () => { + it('emits component event `onFilter` with empty array when `filterValue` is cleared by GlFilteredSearch', () => { + wrapper.setData({ + initialRender: false, + filterValue: [ + { + type: 'filtered-search-term', + value: { data: '' }, + }, + ], + }); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.emitted('onFilter')[0]).toEqual([[]]); + }); + }); + }); + }); + + describe('methods', () => { + describe('setupRecentSearch', () => { + it('initializes `recentSearchesService` and `recentSearchesStore` props when `recentSearchesStorageKey` is available', () => { + expect(wrapper.vm.recentSearchesService instanceof RecentSearchesService).toBe(true); + expect(wrapper.vm.recentSearchesStore instanceof RecentSearchesStore).toBe(true); + }); + + it('initializes `recentSearchesPromise` prop with a promise by using `recentSearchesService.fetch()`', () => { + jest + .spyOn(wrapper.vm.recentSearchesService, 'fetch') + .mockReturnValue(new Promise(() => [])); + + wrapper.vm.setupRecentSearch(); + + expect(wrapper.vm.recentSearchesPromise instanceof Promise).toBe(true); + }); + }); + + describe('getRecentSearches', () => { + it('returns array of strings representing recent searches', () => { + wrapper.vm.recentSearchesStore.setRecentSearches(['foo']); + + expect(wrapper.vm.getRecentSearches()).toEqual(['foo']); + }); + }); + + describe('handleSortOptionClick', () => { + it('emits component event `onSort` with selected sort by value', () => { + wrapper.vm.handleSortOptionClick(mockSortOptions[1]); + + expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[1]); + expect(wrapper.emitted('onSort')[0]).toEqual([mockSortOptions[1].sortDirection.descending]); + }); + }); + + describe('handleSortDirectionClick', () => { + beforeEach(() => { + wrapper.setData({ + selectedSortOption: mockSortOptions[0], + }); + }); + + it('sets `selectedSortDirection` to be opposite of its current value', () => { + expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.descending); + + wrapper.vm.handleSortDirectionClick(); + + expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.ascending); + }); + + it('emits component event `onSort` with opposite of currently selected sort by value', () => { + wrapper.vm.handleSortDirectionClick(); + + expect(wrapper.emitted('onSort')[0]).toEqual([mockSortOptions[0].sortDirection.ascending]); + }); + }); + + describe('handleFilterSubmit', () => { + const mockFilters = [ + { + type: 'author_username', + value: { + data: 'root', + operator: '=', + }, + }, + 'foo', + ]; + + it('calls `recentSearchesStore.addRecentSearch` with serialized value of provided `filters` param', () => { + jest.spyOn(wrapper.vm.recentSearchesStore, 'addRecentSearch'); + // jest.spyOn(wrapper.vm.recentSearchesService, 'save'); + + wrapper.vm.handleFilterSubmit(mockFilters); + + return wrapper.vm.recentSearchesPromise.then(() => { + expect(wrapper.vm.recentSearchesStore.addRecentSearch).toHaveBeenCalledWith( + 'author_username:=@root foo', + ); + }); + }); + + it('calls `recentSearchesService.save` with array of searches', () => { + jest.spyOn(wrapper.vm.recentSearchesService, 'save'); + + wrapper.vm.handleFilterSubmit(mockFilters); + + return wrapper.vm.recentSearchesPromise.then(() => { + expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([ + 'author_username:=@root foo', + ]); + }); + }); + + it('emits component event `onFilter` with provided filters param', () => { + wrapper.vm.handleFilterSubmit(mockFilters); + + expect(wrapper.emitted('onFilter')[0]).toEqual([mockFilters]); + }); + }); + }); + + describe('template', () => { + beforeEach(() => { + wrapper.setData({ + selectedSortOption: mockSortOptions[0], + selectedSortDirection: SortDirection.descending, + }); + + return wrapper.vm.$nextTick(); + }); + + it('renders gl-filtered-search component', () => { + const glFilteredSearchEl = wrapper.find(GlFilteredSearch); + + expect(glFilteredSearchEl.props('placeholder')).toBe('Filter requirements'); + expect(glFilteredSearchEl.props('availableTokens')).toEqual(mockAvailableTokens); + }); + + it('renders sort dropdown component', () => { + expect(wrapper.find(GlButtonGroup).exists()).toBe(true); + expect(wrapper.find(GlDropdown).exists()).toBe(true); + expect(wrapper.find(GlDropdown).props('text')).toBe(mockSortOptions[0].title); + }); + + it('renders dropdown items', () => { + const dropdownItemsEl = wrapper.findAll(GlDropdownItem); + + expect(dropdownItemsEl).toHaveLength(mockSortOptions.length); + expect(dropdownItemsEl.at(0).text()).toBe(mockSortOptions[0].title); + expect(dropdownItemsEl.at(0).props('isChecked')).toBe(true); + expect(dropdownItemsEl.at(1).text()).toBe(mockSortOptions[1].title); + }); + + it('renders sort direction button', () => { + const sortButtonEl = wrapper.find(GlButton); + + expect(sortButtonEl.attributes('title')).toBe('Sort direction: Descending'); + expect(sortButtonEl.props('icon')).toBe('sort-highest'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js new file mode 100644 index 00000000000..edc0f119262 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -0,0 +1,64 @@ +import Api from '~/api'; +import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; + +export const mockAuthor1 = { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://0.0.0.0:3000/root', +}; + +export const mockAuthor2 = { + id: 2, + name: 'Claudio Beer', + username: 'ericka_terry', + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/12a89d115b5a398d5082897ebbcba9c2?s=80&d=identicon', + web_url: 'http://0.0.0.0:3000/ericka_terry', +}; + +export const mockAuthor3 = { + id: 6, + name: 'Shizue Hartmann', + username: 'junita.weimann', + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/9da1abb41b1d4c9c9e81030b71ea61a0?s=80&d=identicon', + web_url: 'http://0.0.0.0:3000/junita.weimann', +}; + +export const mockAuthors = [mockAuthor1, mockAuthor2, mockAuthor3]; + +export const mockAuthorToken = { + type: 'author_username', + icon: 'user', + title: 'Author', + unique: false, + symbol: '@', + token: AuthorToken, + operators: [{ value: '=', description: 'is', default: 'true' }], + fetchPath: 'gitlab-org/gitlab-test', + fetchAuthors: Api.projectUsers.bind(Api), +}; + +export const mockAvailableTokens = [mockAuthorToken]; + +export const mockSortOptions = [ + { + id: 1, + title: 'Created date', + sortDirection: { + descending: 'created_desc', + ascending: 'created_asc', + }, + }, + { + id: 2, + title: 'Last updated', + sortDirection: { + descending: 'updated_desc', + ascending: 'updated_asc', + }, + }, +]; diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js new file mode 100644 index 00000000000..3650ef79136 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js @@ -0,0 +1,150 @@ +import { mount } from '@vue/test-utils'; +import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; + +import createFlash from '~/flash'; +import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; + +import { mockAuthorToken, mockAuthors } from '../mock_data'; + +jest.mock('~/flash'); + +const createComponent = ({ config = mockAuthorToken, value = { data: '' } } = {}) => + mount(AuthorToken, { + propsData: { + config, + value, + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + }, + stubs: { + Portal: { + template: '<div><slot></slot></div>', + }, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, + }, + }); + +describe('AuthorToken', () => { + let mock; + let wrapper; + + beforeEach(() => { + mock = new MockAdapter(axios); + wrapper = createComponent(); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + describe('computed', () => { + describe('currentValue', () => { + it('returns lowercase string for `value.data`', () => { + wrapper.setProps({ + value: { data: 'FOO' }, + }); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.currentValue).toBe('foo'); + }); + }); + }); + + describe('activeAuthor', () => { + it('returns object for currently present `value.data`', () => { + wrapper.setData({ + authors: mockAuthors, + }); + + wrapper.setProps({ + value: { data: mockAuthors[0].username }, + }); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.activeAuthor).toEqual(mockAuthors[0]); + }); + }); + }); + }); + + describe('fetchAuthorBySearchTerm', () => { + it('calls `config.fetchAuthors` with provided searchTerm param', () => { + jest.spyOn(wrapper.vm.config, 'fetchAuthors'); + + wrapper.vm.fetchAuthorBySearchTerm(mockAuthors[0].username); + + expect(wrapper.vm.config.fetchAuthors).toHaveBeenCalledWith( + mockAuthorToken.fetchPath, + mockAuthors[0].username, + ); + }); + + it('sets response to `authors` when request is succesful', () => { + jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockResolvedValue(mockAuthors); + + wrapper.vm.fetchAuthorBySearchTerm('root'); + + return waitForPromises().then(() => { + expect(wrapper.vm.authors).toEqual(mockAuthors); + }); + }); + + it('calls `createFlash` with flash error message when request fails', () => { + jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); + + wrapper.vm.fetchAuthorBySearchTerm('root'); + + return waitForPromises().then(() => { + expect(createFlash).toHaveBeenCalledWith('There was a problem fetching users.'); + }); + }); + + it('sets `loading` to false when request completes', () => { + jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); + + wrapper.vm.fetchAuthorBySearchTerm('root'); + + return waitForPromises().then(() => { + expect(wrapper.vm.loading).toBe(false); + }); + }); + }); + + describe('template', () => { + beforeEach(() => { + wrapper.setData({ + authors: mockAuthors, + }); + + return wrapper.vm.$nextTick(); + }); + + it('renders gl-filtered-search-token component', () => { + expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true); + }); + + it('renders token item when value is selected', () => { + wrapper.setProps({ + value: { data: mockAuthors[0].username }, + }); + + return wrapper.vm.$nextTick(() => { + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + + expect(tokenSegments).toHaveLength(3); // Author, =, "Administrator" + expect(tokenSegments.at(2).text()).toBe(mockAuthors[0].name); // "Administrator" + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/icon_spec.js b/spec/frontend/vue_shared/components/icon_spec.js new file mode 100644 index 00000000000..a448953cc8e --- /dev/null +++ b/spec/frontend/vue_shared/components/icon_spec.js @@ -0,0 +1,78 @@ +import Vue from 'vue'; +import { mount } from '@vue/test-utils'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import Icon from '~/vue_shared/components/icon.vue'; +import iconsPath from '@gitlab/svgs/dist/icons.svg'; + +jest.mock('@gitlab/svgs/dist/icons.svg', () => 'testing'); + +describe('Sprite Icon Component', () => { + describe('Initialization', () => { + let icon; + + beforeEach(() => { + const IconComponent = Vue.extend(Icon); + + icon = mountComponent(IconComponent, { + name: 'commit', + size: 32, + }); + }); + + afterEach(() => { + icon.$destroy(); + }); + + it('should return a defined Vue component', () => { + expect(icon).toBeDefined(); + }); + + it('should have <svg> as a child element', () => { + expect(icon.$el.tagName).toBe('svg'); + }); + + it('should have <use> as a child element with the correct href', () => { + expect(icon.$el.firstChild.tagName).toBe('use'); + expect(icon.$el.firstChild.getAttribute('xlink:href')).toBe(`${iconsPath}#commit`); + }); + + it('should properly compute iconSizeClass', () => { + expect(icon.iconSizeClass).toBe('s32'); + }); + + it('forbids invalid size prop', () => { + expect(icon.$options.props.size.validator(NaN)).toBeFalsy(); + expect(icon.$options.props.size.validator(0)).toBeFalsy(); + expect(icon.$options.props.size.validator(9001)).toBeFalsy(); + }); + + it('should properly render img css', () => { + const { classList } = icon.$el; + const containsSizeClass = classList.contains('s32'); + + expect(containsSizeClass).toBe(true); + }); + + it('`name` validator should return false for non existing icons', () => { + jest.spyOn(console, 'warn').mockImplementation(); + + expect(Icon.props.name.validator('non_existing_icon_sprite')).toBe(false); + }); + + it('`name` validator should return true for existing icons', () => { + expect(Icon.props.name.validator('commit')).toBe(true); + }); + }); + + it('should call registered listeners when they are triggered', () => { + const clickHandler = jest.fn(); + const wrapper = mount(Icon, { + propsData: { name: 'commit' }, + listeners: { click: clickHandler }, + }); + + wrapper.find('svg').trigger('click'); + + expect(clickHandler).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js index dd24ecf707d..9be0a67e4fa 100644 --- a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js +++ b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js @@ -6,6 +6,15 @@ import { defaultAssignees, defaultMilestone } from './related_issuable_mock_data describe('RelatedIssuableItem', () => { let wrapper; + + function mountComponent({ mountMethod = mount, stubs = {}, props = {}, slots = {} } = {}) { + wrapper = mountMethod(RelatedIssuableItem, { + propsData: props, + slots, + stubs, + }); + } + const props = { idKey: 1, displayReference: 'gitlab-org/gitlab-test#1', @@ -26,10 +35,7 @@ describe('RelatedIssuableItem', () => { }; beforeEach(() => { - wrapper = mount(RelatedIssuableItem, { - slots, - propsData: props, - }); + mountComponent({ props, slots }); }); afterEach(() => { diff --git a/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap b/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap index 29ac754de49..cdd7a3ccaf0 100644 --- a/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap +++ b/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap @@ -5,8 +5,11 @@ exports[`Suggestion Diff component matches snapshot 1`] = ` class="md-suggestion" > <suggestion-diff-header-stub + batchsuggestionscount="1" class="qa-suggestion-diff-header js-suggestion-diff-header" helppagepath="path_to_docs" + isapplyingbatch="true" + isbatched="true" /> <table diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index 54ce1f47e28..74be5f8230e 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -185,7 +185,7 @@ describe('Markdown field component', () => { markdownButton.trigger('click'); return wrapper.vm.$nextTick(() => { - expect(textarea.value).toContain('* testing'); + expect(textarea.value).toContain('- testing'); }); }); @@ -197,7 +197,7 @@ describe('Markdown field component', () => { markdownButton.trigger('click'); return wrapper.vm.$nextTick(() => { - expect(textarea.value).toContain('* testing\n* 123'); + expect(textarea.value).toContain('- testing\n- 123'); }); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js index 9b9c3d559e3..9a5b95b555f 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js @@ -3,20 +3,29 @@ import { shallowMount } from '@vue/test-utils'; import SuggestionDiffHeader from '~/vue_shared/components/markdown/suggestion_diff_header.vue'; const DEFAULT_PROPS = { + batchSuggestionsCount: 2, canApply: true, isApplied: false, + isBatched: false, + isApplyingBatch: false, helpPagePath: 'path_to_docs', }; describe('Suggestion Diff component', () => { let wrapper; - const createComponent = props => { + const createComponent = (props, glFeatures = {}) => { wrapper = shallowMount(SuggestionDiffHeader, { propsData: { ...DEFAULT_PROPS, ...props, }, + provide: { + glFeatures: { + batchSuggestions: true, + ...glFeatures, + }, + }, }); }; @@ -25,6 +34,9 @@ describe('Suggestion Diff component', () => { }); const findApplyButton = () => wrapper.find('.js-apply-btn'); + const findApplyBatchButton = () => wrapper.find('.js-apply-batch-btn'); + const findAddToBatchButton = () => wrapper.find('.js-add-to-batch-btn'); + const findRemoveFromBatchButton = () => wrapper.find('.js-remove-from-batch-btn'); const findHeader = () => wrapper.find('.js-suggestion-diff-header'); const findHelpButton = () => wrapper.find('.js-help-btn'); const findLoading = () => wrapper.find(GlLoadingIcon); @@ -44,19 +56,22 @@ describe('Suggestion Diff component', () => { expect(findHelpButton().exists()).toBe(true); }); - it('renders an apply button', () => { + it('renders apply suggestion and add to batch buttons', () => { createComponent(); const applyBtn = findApplyButton(); + const addToBatchBtn = findAddToBatchButton(); expect(applyBtn.exists()).toBe(true); expect(applyBtn.html().includes('Apply suggestion')).toBe(true); - }); - it('does not render an apply button if `canApply` is set to false', () => { - createComponent({ canApply: false }); + expect(addToBatchBtn.exists()).toBe(true); + expect(addToBatchBtn.html().includes('Add suggestion to batch')).toBe(true); + }); - expect(findApplyButton().exists()).toBe(false); + it('renders correct tooltip message for apply button', () => { + createComponent(); + expect(wrapper.vm.tooltipMessage).toBe('This also resolves the discussion'); }); describe('when apply suggestion is clicked', () => { @@ -73,13 +88,14 @@ describe('Suggestion Diff component', () => { }); }); - it('hides apply button', () => { + it('does not render apply suggestion and add to batch buttons', () => { expect(findApplyButton().exists()).toBe(false); + expect(findAddToBatchButton().exists()).toBe(false); }); it('shows loading', () => { expect(findLoading().exists()).toBe(true); - expect(wrapper.text()).toContain('Applying suggestion'); + expect(wrapper.text()).toContain('Applying suggestion...'); }); it('when callback of apply is called, hides loading', () => { @@ -93,4 +109,135 @@ describe('Suggestion Diff component', () => { }); }); }); + + describe('when add to batch is clicked', () => { + it('emits addToBatch', () => { + createComponent(); + + findAddToBatchButton().vm.$emit('click'); + + expect(wrapper.emittedByOrder()).toContainEqual({ + name: 'addToBatch', + args: [], + }); + }); + }); + + describe('when remove from batch is clicked', () => { + it('emits removeFromBatch', () => { + createComponent({ isBatched: true }); + + findRemoveFromBatchButton().vm.$emit('click'); + + expect(wrapper.emittedByOrder()).toContainEqual({ + name: 'removeFromBatch', + args: [], + }); + }); + }); + + describe('apply suggestions is clicked', () => { + it('emits applyBatch', () => { + createComponent({ isBatched: true }); + + findApplyBatchButton().vm.$emit('click'); + + expect(wrapper.emittedByOrder()).toContainEqual({ + name: 'applyBatch', + args: [], + }); + }); + }); + + describe('when isBatched is true', () => { + it('shows remove from batch and apply batch buttons and displays the batch count', () => { + createComponent({ + batchSuggestionsCount: 9, + isBatched: true, + }); + + const applyBatchBtn = findApplyBatchButton(); + const removeFromBatchBtn = findRemoveFromBatchButton(); + + expect(removeFromBatchBtn.exists()).toBe(true); + expect(removeFromBatchBtn.html().includes('Remove from batch')).toBe(true); + + expect(applyBatchBtn.exists()).toBe(true); + expect(applyBatchBtn.html().includes('Apply suggestions')).toBe(true); + expect(applyBatchBtn.html().includes(String('9'))).toBe(true); + }); + + it('hides add to batch and apply buttons', () => { + createComponent({ + isBatched: true, + }); + + expect(findApplyButton().exists()).toBe(false); + expect(findAddToBatchButton().exists()).toBe(false); + }); + + describe('when isBatched and isApplyingBatch are true', () => { + it('shows loading', () => { + createComponent({ + isBatched: true, + isApplyingBatch: true, + }); + + expect(findLoading().exists()).toBe(true); + expect(wrapper.text()).toContain('Applying suggestions...'); + }); + + it('adjusts message for batch with single suggestion', () => { + createComponent({ + batchSuggestionsCount: 1, + isBatched: true, + isApplyingBatch: true, + }); + + expect(findLoading().exists()).toBe(true); + expect(wrapper.text()).toContain('Applying suggestion...'); + }); + + it('hides remove from batch and apply suggestions buttons', () => { + createComponent({ + isBatched: true, + isApplyingBatch: true, + }); + + expect(findRemoveFromBatchButton().exists()).toBe(false); + expect(findApplyBatchButton().exists()).toBe(false); + }); + }); + }); + + describe('batchSuggestions feature flag is set to false', () => { + beforeEach(() => { + createComponent({}, { batchSuggestions: false }); + }); + + it('disables add to batch buttons but keeps apply suggestion enabled', () => { + expect(findApplyButton().exists()).toBe(true); + expect(findAddToBatchButton().exists()).toBe(false); + expect(findApplyButton().attributes('disabled')).not.toBe('true'); + }); + }); + + describe('canApply is set to false', () => { + beforeEach(() => { + createComponent({ canApply: false }); + }); + + it('disables apply suggestion and add to batch buttons', () => { + expect(findApplyButton().exists()).toBe(true); + expect(findAddToBatchButton().exists()).toBe(true); + expect(findApplyButton().attributes('disabled')).toBe('true'); + expect(findAddToBatchButton().attributes('disabled')).toBe('true'); + }); + + it('renders correct tooltip message for apply button', () => { + expect(wrapper.vm.tooltipMessage).toBe( + "Can't apply as this line has changed or the suggestion already matches its content.", + ); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js index 162ac495385..232feb126dc 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js @@ -3,9 +3,10 @@ import SuggestionDiffComponent from '~/vue_shared/components/markdown/suggestion import SuggestionDiffHeader from '~/vue_shared/components/markdown/suggestion_diff_header.vue'; import SuggestionDiffRow from '~/vue_shared/components/markdown/suggestion_diff_row.vue'; +const suggestionId = 1; const MOCK_DATA = { suggestion: { - id: 1, + id: suggestionId, diff_lines: [ { can_receive_suggestion: false, @@ -38,8 +39,10 @@ const MOCK_DATA = { type: 'new', }, ], + is_applying_batch: true, }, helpPagePath: 'path_to_docs', + batchSuggestionsInfo: [{ suggestionId }], }; describe('Suggestion Diff component', () => { @@ -70,17 +73,24 @@ describe('Suggestion Diff component', () => { expect(wrapper.findAll(SuggestionDiffRow)).toHaveLength(3); }); - it('emits apply event on sugestion diff header apply', () => { - wrapper.find(SuggestionDiffHeader).vm.$emit('apply', 'test-event'); + it.each` + event | childArgs | args + ${'apply'} | ${['test-event']} | ${[{ callback: 'test-event', suggestionId }]} + ${'applyBatch'} | ${[]} | ${[]} + ${'addToBatch'} | ${[]} | ${[suggestionId]} + ${'removeFromBatch'} | ${[]} | ${[suggestionId]} + `('emits $event event on sugestion diff header $event', ({ event, childArgs, args }) => { + wrapper.find(SuggestionDiffHeader).vm.$emit(event, ...childArgs); - expect(wrapper.emitted('apply')).toBeDefined(); - expect(wrapper.emitted('apply')).toEqual([ - [ - { - callback: 'test-event', - suggestionId: 1, - }, - ], - ]); + expect(wrapper.emitted(event)).toBeDefined(); + expect(wrapper.emitted(event)).toEqual([args]); + }); + + it('passes suggestion batch props to suggestion diff header', () => { + expect(wrapper.find(SuggestionDiffHeader).props()).toMatchObject({ + batchSuggestionsCount: 1, + isBatched: true, + isApplyingBatch: MOCK_DATA.suggestion.is_applying_batch, + }); }); }); diff --git a/spec/frontend/vue_shared/components/panel_resizer_spec.js b/spec/frontend/vue_shared/components/panel_resizer_spec.js new file mode 100644 index 00000000000..d8b903e5bfd --- /dev/null +++ b/spec/frontend/vue_shared/components/panel_resizer_spec.js @@ -0,0 +1,85 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import panelResizer from '~/vue_shared/components/panel_resizer.vue'; + +describe('Panel Resizer component', () => { + let vm; + let PanelResizer; + + const triggerEvent = (eventName, el = vm.$el, clientX = 0) => { + const event = document.createEvent('MouseEvents'); + event.initMouseEvent( + eventName, + true, + true, + window, + 1, + clientX, + 0, + clientX, + 0, + false, + false, + false, + false, + 0, + null, + ); + + el.dispatchEvent(event); + }; + + beforeEach(() => { + PanelResizer = Vue.extend(panelResizer); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render a div element with the correct classes and styles', () => { + vm = mountComponent(PanelResizer, { + startSize: 100, + side: 'left', + }); + + expect(vm.$el.tagName).toEqual('DIV'); + expect(vm.$el.getAttribute('class')).toBe( + 'position-absolute position-top-0 position-bottom-0 drag-handle position-left-0', + ); + + expect(vm.$el.getAttribute('style')).toBe('cursor: ew-resize;'); + }); + + it('should render a div element with the correct classes for a right side panel', () => { + vm = mountComponent(PanelResizer, { + startSize: 100, + side: 'right', + }); + + expect(vm.$el.tagName).toEqual('DIV'); + expect(vm.$el.getAttribute('class')).toBe( + 'position-absolute position-top-0 position-bottom-0 drag-handle position-right-0', + ); + }); + + it('drag the resizer', () => { + vm = mountComponent(PanelResizer, { + startSize: 100, + side: 'left', + }); + + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + triggerEvent('mousedown', vm.$el); + triggerEvent('mousemove', document); + triggerEvent('mouseup', document); + + expect(vm.$emit.mock.calls).toEqual([ + ['resize-start', 100], + ['update:size', 100], + ['resize-end', 100], + ]); + + expect(vm.size).toBe(100); + }); +}); diff --git a/spec/frontend/vue_shared/components/pikaday_spec.js b/spec/frontend/vue_shared/components/pikaday_spec.js index 867bf88ff50..639b4828a09 100644 --- a/spec/frontend/vue_shared/components/pikaday_spec.js +++ b/spec/frontend/vue_shared/components/pikaday_spec.js @@ -1,30 +1,42 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { shallowMount } from '@vue/test-utils'; import datePicker from '~/vue_shared/components/pikaday.vue'; describe('datePicker', () => { - let vm; + let wrapper; beforeEach(() => { - const DatePicker = Vue.extend(datePicker); - vm = mountComponent(DatePicker, { - label: 'label', + wrapper = shallowMount(datePicker, { + propsData: { + label: 'label', + }, + attachToDocument: true, }); }); + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + it('should render label text', () => { - expect(vm.$el.querySelector('.dropdown-toggle-text').innerText.trim()).toEqual('label'); + expect( + wrapper + .find('.dropdown-toggle-text') + .text() + .trim(), + ).toEqual('label'); }); it('should show calendar', () => { - expect(vm.$el.querySelector('.pika-single')).toBeDefined(); + expect(wrapper.find('.pika-single').element).toBeDefined(); }); - it('should toggle when dropdown is clicked', () => { - const hidePicker = jest.fn(); - vm.$on('hidePicker', hidePicker); + it('should emit hidePicker event when dropdown is clicked', () => { + // Removing the bootstrap data-toggle property, + // because it interfers with our click event + delete wrapper.find('.dropdown-menu-toggle').element.dataset.toggle; - vm.$el.querySelector('.dropdown-menu-toggle').click(); + wrapper.find('.dropdown-menu-toggle').trigger('click'); - expect(hidePicker).toHaveBeenCalled(); + expect(wrapper.emitted('hidePicker')).toEqual([[]]); }); }); diff --git a/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js index 29bced394dc..6d1ebe85aa0 100644 --- a/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js +++ b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js @@ -29,6 +29,7 @@ describe('ProjectSelector component', () => { showMinimumSearchQueryMessage: false, showLoadingIndicator: false, showSearchErrorMessage: false, + totalResults: searchResults.length, }, attachToDocument: true, }); @@ -109,4 +110,26 @@ describe('ProjectSelector component', () => { ); }); }); + + describe('the search results legend', () => { + it.each` + count | total | expected + ${0} | ${0} | ${'Showing 0 projects'} + ${1} | ${0} | ${'Showing 1 project'} + ${2} | ${0} | ${'Showing 2 projects'} + ${2} | ${3} | ${'Showing 2 of 3 projects'} + `( + 'is "$expected" given $count results are showing out of $total', + ({ count, total, expected }) => { + wrapper.setProps({ + projectSearchResults: searchResults.slice(0, count), + totalResults: total, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.text()).toContain(expected); + }); + }, + ); + }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js new file mode 100644 index 00000000000..faa32131fab --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js @@ -0,0 +1,77 @@ +import { + generateToolbarItem, + addCustomEventListener, + removeCustomEventListener, + addImage, + getMarkdown, +} from '~/vue_shared/components/rich_content_editor/editor_service'; + +describe('Editor Service', () => { + const mockInstance = { + eventManager: { addEventType: jest.fn(), removeEventHandler: jest.fn(), listen: jest.fn() }, + editor: { exec: jest.fn() }, + invoke: jest.fn(), + }; + const event = 'someCustomEvent'; + const handler = jest.fn(); + + describe('generateToolbarItem', () => { + const config = { + icon: 'bold', + command: 'some-command', + tooltip: 'Some Tooltip', + event: 'some-event', + }; + + const generatedItem = generateToolbarItem(config); + + it('generates the correct command', () => { + expect(generatedItem.options.command).toBe(config.command); + }); + + it('generates the correct event', () => { + expect(generatedItem.options.event).toBe(config.event); + }); + + it('generates a divider when isDivider is set to true', () => { + const isDivider = true; + + expect(generateToolbarItem({ isDivider })).toBe('divider'); + }); + }); + + describe('addCustomEventListener', () => { + it('registers an event type on the instance and adds an event handler', () => { + addCustomEventListener(mockInstance, event, handler); + + expect(mockInstance.eventManager.addEventType).toHaveBeenCalledWith(event); + expect(mockInstance.eventManager.listen).toHaveBeenCalledWith(event, handler); + }); + }); + + describe('removeCustomEventListener', () => { + it('removes an event handler from the instance', () => { + removeCustomEventListener(mockInstance, event, handler); + + expect(mockInstance.eventManager.removeEventHandler).toHaveBeenCalledWith(event, handler); + }); + }); + + describe('addImage', () => { + it('calls the exec method on the instance', () => { + const mockImage = { imageUrl: 'some/url.png', description: 'some description' }; + + addImage(mockInstance, mockImage); + + expect(mockInstance.editor.exec).toHaveBeenCalledWith('AddImage', mockImage); + }); + }); + + describe('getMarkdown', () => { + it('calls the invoke method on the instance', () => { + getMarkdown(mockInstance); + + expect(mockInstance.invoke).toHaveBeenCalledWith('getMarkdown'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image_modal_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image_modal_spec.js new file mode 100644 index 00000000000..4889bc8538d --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image_modal_spec.js @@ -0,0 +1,41 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; +import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image_modal.vue'; + +describe('Add Image Modal', () => { + let wrapper; + + const findModal = () => wrapper.find(GlModal); + const findUrlInput = () => wrapper.find({ ref: 'urlInput' }); + const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' }); + + beforeEach(() => { + wrapper = shallowMount(AddImageModal); + }); + + describe('when content is loaded', () => { + it('renders a modal component', () => { + expect(findModal().exists()).toBe(true); + }); + + it('renders an input to add an image URL', () => { + expect(findUrlInput().exists()).toBe(true); + }); + + it('renders an input to add an image description', () => { + expect(findDescriptionInput().exists()).toBe(true); + }); + }); + + describe('add image', () => { + it('emits an addImage event when a valid URL is specified', () => { + const preventDefault = jest.fn(); + const mockImage = { imageUrl: '/some/valid/url.png', altText: 'some description' }; + wrapper.setData({ ...mockImage }); + + findModal().vm.$emit('ok', { preventDefault }); + expect(preventDefault).not.toHaveBeenCalled(); + expect(wrapper.emitted('addImage')).toEqual([[mockImage]]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js index 549d89171c6..0db10389df4 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js @@ -1,17 +1,33 @@ import { shallowMount } from '@vue/test-utils'; import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; +import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image_modal.vue'; import { EDITOR_OPTIONS, EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE, + CUSTOM_EVENTS, } from '~/vue_shared/components/rich_content_editor/constants'; +import { + addCustomEventListener, + removeCustomEventListener, + addImage, +} from '~/vue_shared/components/rich_content_editor/editor_service'; + +jest.mock('~/vue_shared/components/rich_content_editor/editor_service', () => ({ + ...jest.requireActual('~/vue_shared/components/rich_content_editor/editor_service'), + addCustomEventListener: jest.fn(), + removeCustomEventListener: jest.fn(), + addImage: jest.fn(), +})); + describe('Rich Content Editor', () => { let wrapper; const value = '## Some Markdown'; const findEditor = () => wrapper.find({ ref: 'editor' }); + const findAddImageModal = () => wrapper.find(AddImageModal); beforeEach(() => { wrapper = shallowMount(RichContentEditor, { @@ -56,4 +72,47 @@ describe('Rich Content Editor', () => { expect(wrapper.emitted().input[0][0]).toBe(changedMarkdown); }); }); + + describe('when editor is loaded', () => { + it('adds the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { + const mockEditorApi = { eventManager: { addEventType: jest.fn(), listen: jest.fn() } }; + findEditor().vm.$emit('load', mockEditorApi); + + expect(addCustomEventListener).toHaveBeenCalledWith( + mockEditorApi, + CUSTOM_EVENTS.openAddImageModal, + wrapper.vm.onOpenAddImageModal, + ); + }); + }); + + describe('when editor is destroyed', () => { + it('removes the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { + const mockEditorApi = { eventManager: { removeEventHandler: jest.fn() } }; + + wrapper.vm.editorApi = mockEditorApi; + wrapper.vm.$destroy(); + + expect(removeCustomEventListener).toHaveBeenCalledWith( + mockEditorApi, + CUSTOM_EVENTS.openAddImageModal, + wrapper.vm.onOpenAddImageModal, + ); + }); + }); + + describe('add image modal', () => { + it('renders an addImageModal component', () => { + expect(findAddImageModal().exists()).toBe(true); + }); + + it('calls the onAddImage method when the addImage event is emitted', () => { + const mockImage = { imageUrl: 'some/url.png', description: 'some description' }; + const mockInstance = { exec: jest.fn() }; + wrapper.vm.$refs.editor = mockInstance; + + findAddImageModal().vm.$emit('addImage', mockImage); + expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js index 8545c43dc1e..2db15a71215 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { GlIcon } from '@gitlab/ui'; import ToolbarItem from '~/vue_shared/components/rich_content_editor/toolbar_item.vue'; @@ -9,33 +10,45 @@ describe('Toolbar Item', () => { const findButton = () => wrapper.find('button'); const buildWrapper = propsData => { - wrapper = shallowMount(ToolbarItem, { propsData }); + wrapper = shallowMount(ToolbarItem, { + propsData, + directives: { + GlTooltip: createMockDirective(), + }, + }); }; describe.each` - icon - ${'heading'} - ${'bold'} - ${'italic'} - ${'strikethrough'} - ${'quote'} - ${'link'} - ${'doc-code'} - ${'list-bulleted'} - ${'list-numbered'} - ${'list-task'} - ${'list-indent'} - ${'list-outdent'} - ${'dash'} - ${'table'} - ${'code'} - `('toolbar item component', ({ icon }) => { - beforeEach(() => buildWrapper({ icon })); + icon | tooltip + ${'heading'} | ${'Headings'} + ${'bold'} | ${'Add bold text'} + ${'italic'} | ${'Add italic text'} + ${'strikethrough'} | ${'Add strikethrough text'} + ${'quote'} | ${'Insert a quote'} + ${'link'} | ${'Add a link'} + ${'doc-code'} | ${'Insert a code block'} + ${'list-bulleted'} | ${'Add a bullet list'} + ${'list-numbered'} | ${'Add a numbered list'} + ${'list-task'} | ${'Add a task list'} + ${'list-indent'} | ${'Indent'} + ${'list-outdent'} | ${'Outdent'} + ${'dash'} | ${'Add a line'} + ${'table'} | ${'Add a table'} + ${'code'} | ${'Insert an image'} + ${'code'} | ${'Insert inline code'} + `('toolbar item component', ({ icon, tooltip }) => { + beforeEach(() => buildWrapper({ icon, tooltip })); it('renders a toolbar button', () => { expect(findButton().exists()).toBe(true); }); + it('renders the correct tooltip', () => { + const buttonTooltip = getBinding(wrapper.element, 'gl-tooltip'); + expect(buttonTooltip).toBeDefined(); + expect(buttonTooltip.value.title).toBe(tooltip); + }); + it(`renders the ${icon} icon`, () => { expect(findIcon().exists()).toBe(true); expect(findIcon().props().name).toBe(icon); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js deleted file mode 100644 index 7605cc6a22c..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js +++ /dev/null @@ -1,29 +0,0 @@ -import { generateToolbarItem } from '~/vue_shared/components/rich_content_editor/toolbar_service'; - -describe('Toolbar Service', () => { - const config = { - icon: 'bold', - command: 'some-command', - tooltip: 'Some Tooltip', - event: 'some-event', - }; - const generatedItem = generateToolbarItem(config); - - it('generates the correct command', () => { - expect(generatedItem.options.command).toBe(config.command); - }); - - it('generates the correct tooltip', () => { - expect(generatedItem.options.tooltip).toBe(config.tooltip); - }); - - it('generates the correct event', () => { - expect(generatedItem.options.event).toBe(config.event); - }); - - it('generates a divider when isDivider is set to true', () => { - const isDivider = true; - - expect(generateToolbarItem({ isDivider })).toBe('divider'); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/date_picker_spec.js b/spec/frontend/vue_shared/components/sidebar/date_picker_spec.js index 198af09c9f5..47edfbe3115 100644 --- a/spec/frontend/vue_shared/components/sidebar/date_picker_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/date_picker_spec.js @@ -1,121 +1,149 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import sidebarDatePicker from '~/vue_shared/components/sidebar/date_picker.vue'; - -describe('sidebarDatePicker', () => { - let vm; - beforeEach(() => { - const SidebarDatePicker = Vue.extend(sidebarDatePicker); - vm = mountComponent(SidebarDatePicker, { - label: 'label', - isLoading: true, +import { mount } from '@vue/test-utils'; +import SidebarDatePicker from '~/vue_shared/components/sidebar/date_picker.vue'; +import DatePicker from '~/vue_shared/components/pikaday.vue'; + +describe('SidebarDatePicker', () => { + let wrapper; + + const mountComponent = (propsData = {}, data = {}) => { + if (wrapper) { + throw new Error('tried to call mountComponent without d'); + } + wrapper = mount(SidebarDatePicker, { + stubs: { + DatePicker: true, + }, + propsData, + data: () => data, }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; }); it('should emit toggleCollapse when collapsed toggle sidebar is clicked', () => { - const toggleCollapse = jest.fn(); - vm.$on('toggleCollapse', toggleCollapse); + mountComponent(); - vm.$el.querySelector('.issuable-sidebar-header .gutter-toggle').click(); + wrapper.find('.issuable-sidebar-header .gutter-toggle').element.click(); - expect(toggleCollapse).toHaveBeenCalled(); + expect(wrapper.emitted('toggleCollapse')).toEqual([[]]); }); it('should render collapsed-calendar-icon', () => { - expect(vm.$el.querySelector('.sidebar-collapsed-icon')).toBeDefined(); + mountComponent(); + + expect(wrapper.find('.sidebar-collapsed-icon').element).toBeDefined(); }); - it('should render label', () => { - expect(vm.$el.querySelector('.title').innerText.trim()).toEqual('label'); + it('should render value when not editing', () => { + mountComponent(); + + expect(wrapper.find('.value-content').element).toBeDefined(); }); - it('should render loading-icon when isLoading', () => { - expect(vm.$el.querySelector('.fa-spin')).toBeDefined(); + it('should render None if there is no selectedDate', () => { + mountComponent(); + + expect( + wrapper + .find('.value-content span') + .text() + .trim(), + ).toEqual('None'); }); - it('should render value when not editing', () => { - expect(vm.$el.querySelector('.value-content')).toBeDefined(); + it('should render date-picker when editing', () => { + mountComponent({}, { editing: true }); + + expect(wrapper.find(DatePicker).element).toBeDefined(); }); - it('should render None if there is no selectedDate', () => { - expect(vm.$el.querySelector('.value-content span').innerText.trim()).toEqual('None'); + it('should render label', () => { + const label = 'label'; + mountComponent({ label }); + expect( + wrapper + .find('.title') + .text() + .trim(), + ).toEqual(label); }); - it('should render date-picker when editing', done => { - vm.editing = true; - Vue.nextTick(() => { - expect(vm.$el.querySelector('.pika-label')).toBeDefined(); - done(); - }); + it('should render loading-icon when isLoading', () => { + mountComponent({ isLoading: true }); + expect(wrapper.find('.gl-spinner').element).toBeDefined(); }); describe('editable', () => { - beforeEach(done => { - vm.editable = true; - Vue.nextTick(done); + beforeEach(() => { + mountComponent({ editable: true }); }); it('should render edit button', () => { - expect(vm.$el.querySelector('.title .btn-blank').innerText.trim()).toEqual('Edit'); + expect( + wrapper + .find('.title .btn-blank') + .text() + .trim(), + ).toEqual('Edit'); }); - it('should enable editing when edit button is clicked', done => { - vm.isLoading = false; - Vue.nextTick(() => { - vm.$el.querySelector('.title .btn-blank').click(); + it('should enable editing when edit button is clicked', async () => { + wrapper.find('.title .btn-blank').element.click(); + + await wrapper.vm.$nextTick(); - expect(vm.editing).toEqual(true); - done(); - }); + expect(wrapper.vm.editing).toEqual(true); }); }); - it('should render date if selectedDate', done => { - vm.selectedDate = new Date('07/07/2017'); - Vue.nextTick(() => { - expect(vm.$el.querySelector('.value-content strong').innerText.trim()).toEqual('Jul 7, 2017'); - done(); - }); + it('should render date if selectedDate', () => { + mountComponent({ selectedDate: new Date('07/07/2017') }); + + expect( + wrapper + .find('.value-content strong') + .text() + .trim(), + ).toEqual('Jul 7, 2017'); }); describe('selectedDate and editable', () => { - beforeEach(done => { - vm.selectedDate = new Date('07/07/2017'); - vm.editable = true; - Vue.nextTick(done); + beforeEach(() => { + mountComponent({ selectedDate: new Date('07/07/2017'), editable: true }); }); it('should render remove button if selectedDate and editable', () => { - expect(vm.$el.querySelector('.value-content .btn-blank').innerText.trim()).toEqual('remove'); + expect( + wrapper + .find('.value-content .btn-blank') + .text() + .trim(), + ).toEqual('remove'); }); - it('should emit saveDate when remove button is clicked', () => { - const saveDate = jest.fn(); - vm.$on('saveDate', saveDate); + it('should emit saveDate with null when remove button is clicked', () => { + wrapper.find('.value-content .btn-blank').element.click(); - vm.$el.querySelector('.value-content .btn-blank').click(); - - expect(saveDate).toHaveBeenCalled(); + expect(wrapper.emitted('saveDate')).toEqual([[null]]); }); }); describe('showToggleSidebar', () => { - beforeEach(done => { - vm.showToggleSidebar = true; - Vue.nextTick(done); + beforeEach(() => { + mountComponent({ showToggleSidebar: true }); }); it('should render toggle-sidebar when showToggleSidebar', () => { - expect(vm.$el.querySelector('.title .gutter-toggle')).toBeDefined(); + expect(wrapper.find('.title .gutter-toggle').element).toBeDefined(); }); it('should emit toggleCollapse when toggle sidebar is clicked', () => { - const toggleCollapse = jest.fn(); - vm.$on('toggleCollapse', toggleCollapse); - - vm.$el.querySelector('.title .gutter-toggle').click(); + wrapper.find('.title .gutter-toggle').element.click(); - expect(toggleCollapse).toHaveBeenCalled(); + expect(wrapper.emitted('toggleCollapse')).toEqual([[]]); }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js index 74c769f86a3..1504e1521d3 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -3,6 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import { GlButton, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; +import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue'; import LabelItem from '~/vue_shared/components/sidebar/labels_select_vue/label_item.vue'; @@ -224,6 +225,10 @@ describe('DropdownContentsLabelsView', () => { expect(searchInputEl.attributes('autofocus')).toBe('true'); }); + it('renders smart-virtual-list element', () => { + expect(wrapper.find(SmartVirtualList).exists()).toBe(true); + }); + it('renders label elements for all labels', () => { expect(wrapper.findAll(LabelItem)).toHaveLength(mockLabels.length); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js index 401d208da5c..ad3f073fdf9 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js @@ -4,10 +4,13 @@ import { GlIcon, GlLink } from '@gitlab/ui'; import LabelItem from '~/vue_shared/components/sidebar/labels_select_vue/label_item.vue'; import { mockRegularLabel } from './mock_data'; -const createComponent = ({ label = mockRegularLabel, highlight = true } = {}) => +const mockLabel = { ...mockRegularLabel, set: true }; + +const createComponent = ({ label = mockLabel, highlight = true } = {}) => shallowMount(LabelItem, { propsData: { label, + isLabelSet: label.set, highlight, }, }); @@ -28,13 +31,29 @@ describe('LabelItem', () => { it('returns an object containing `backgroundColor` based on `label` prop', () => { expect(wrapper.vm.labelBoxStyle).toEqual( expect.objectContaining({ - backgroundColor: mockRegularLabel.color, + backgroundColor: mockLabel.color, }), ); }); }); }); + describe('watchers', () => { + describe('isLabelSet', () => { + it('sets value of `isLabelSet` to `isSet` data prop', () => { + expect(wrapper.vm.isSet).toBe(true); + + wrapper.setProps({ + isLabelSet: false, + }); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.isSet).toBe(false); + }); + }); + }); + }); + describe('methods', () => { describe('handleClick', () => { it('sets value of `isSet` data prop to opposite of its current value', () => { @@ -52,7 +71,7 @@ describe('LabelItem', () => { wrapper.vm.handleClick(); expect(wrapper.emitted('clickLabel')).toBeTruthy(); - expect(wrapper.emitted('clickLabel')[0]).toEqual([mockRegularLabel]); + expect(wrapper.emitted('clickLabel')[0]).toEqual([mockLabel]); }); }); }); @@ -105,7 +124,7 @@ describe('LabelItem', () => { }); it('renders label title', () => { - expect(wrapper.text()).toContain(mockRegularLabel.title); + expect(wrapper.text()).toContain(mockLabel.title); }); }); }); diff --git a/spec/frontend/vue_shared/components/smart_virtual_list_spec.js b/spec/frontend/vue_shared/components/smart_virtual_list_spec.js new file mode 100644 index 00000000000..e5f9b94128e --- /dev/null +++ b/spec/frontend/vue_shared/components/smart_virtual_list_spec.js @@ -0,0 +1,83 @@ +import Vue from 'vue'; +import { mount } from '@vue/test-utils'; +import SmartVirtualScrollList from '~/vue_shared/components/smart_virtual_list.vue'; + +describe('Toggle Button', () => { + let vm; + + const createComponent = ({ length, remain }) => { + const smartListProperties = { + rtag: 'section', + wtag: 'ul', + wclass: 'test-class', + // Size in pixels does not matter for our tests here + size: 35, + length, + remain, + }; + + const Component = Vue.extend({ + components: { + SmartVirtualScrollList, + }, + smartListProperties, + items: Array(length).fill(1), + template: ` + <smart-virtual-scroll-list v-bind="$options.smartListProperties"> + <li v-for="(val, key) in $options.items" :key="key">{{ key + 1 }}</li> + </smart-virtual-scroll-list>`, + }); + + return mount(Component).vm; + }; + + afterEach(() => { + vm.$destroy(); + }); + + describe('if the list is shorter than the maximum shown elements', () => { + const listLength = 10; + + beforeEach(() => { + vm = createComponent({ length: listLength, remain: 20 }); + }); + + it('renders without the vue-virtual-scroll-list component', () => { + expect(vm.$el.classList).not.toContain('js-virtual-list'); + expect(vm.$el.classList).toContain('js-plain-element'); + }); + + it('renders list with provided tags and classes for the wrapper elements', () => { + expect(vm.$el.tagName).toEqual('SECTION'); + expect(vm.$el.firstChild.tagName).toEqual('UL'); + expect(vm.$el.firstChild.classList).toContain('test-class'); + }); + + it('renders all children list elements', () => { + expect(vm.$el.querySelectorAll('li').length).toEqual(listLength); + }); + }); + + describe('if the list is longer than the maximum shown elements', () => { + const maxItemsShown = 20; + + beforeEach(() => { + vm = createComponent({ length: 1000, remain: maxItemsShown }); + }); + + it('uses the vue-virtual-scroll-list component', () => { + expect(vm.$el.classList).toContain('js-virtual-list'); + expect(vm.$el.classList).not.toContain('js-plain-element'); + }); + + it('renders list with provided tags and classes for the wrapper elements', () => { + expect(vm.$el.tagName).toEqual('SECTION'); + expect(vm.$el.firstChild.tagName).toEqual('UL'); + expect(vm.$el.firstChild.classList).toContain('test-class'); + }); + + it('renders at max twice the maximum shown elements', () => { + expect(vm.$el.querySelectorAll('li').length).toBeLessThanOrEqual(2 * maxItemsShown); + }); + }); +}); |