Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/vue_shared')
-rw-r--r--spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js178
-rw-r--r--spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js274
-rw-r--r--spec/frontend/vue_shared/components/deprecated_modal_2_spec.js258
-rw-r--r--spec/frontend/vue_shared/components/deprecated_modal_spec.js73
-rw-r--r--spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js30
-rw-r--r--spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js283
-rw-r--r--spec/frontend/vue_shared/components/file_finder/index_spec.js368
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js259
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js64
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js150
-rw-r--r--spec/frontend/vue_shared/components/icon_spec.js78
-rw-r--r--spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js14
-rw-r--r--spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap3
-rw-r--r--spec/frontend/vue_shared/components/markdown/field_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js163
-rw-r--r--spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js34
-rw-r--r--spec/frontend/vue_shared/components/panel_resizer_spec.js85
-rw-r--r--spec/frontend/vue_shared/components/pikaday_spec.js38
-rw-r--r--spec/frontend/vue_shared/components/project_selector/project_selector_spec.js23
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js77
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/modals/add_image_modal_spec.js41
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js59
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js51
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js29
-rw-r--r--spec/frontend/vue_shared/components/sidebar/date_picker_spec.js162
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js27
-rw-r--r--spec/frontend/vue_shared/components/smart_virtual_list_spec.js83
-rw-r--r--spec/frontend/vue_shared/directives/autofocusonshow_spec.js46
-rw-r--r--spec/frontend/vue_shared/directives/tooltip_spec.js98
-rw-r--r--spec/frontend/vue_shared/translate_spec.js214
31 files changed, 2934 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);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/directives/autofocusonshow_spec.js b/spec/frontend/vue_shared/directives/autofocusonshow_spec.js
new file mode 100644
index 00000000000..90530b7d5c2
--- /dev/null
+++ b/spec/frontend/vue_shared/directives/autofocusonshow_spec.js
@@ -0,0 +1,46 @@
+import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
+
+/**
+ * We're testing this directive's hooks as pure functions
+ * since behaviour of this directive is highly-dependent
+ * on underlying DOM methods.
+ */
+describe('AutofocusOnShow directive', () => {
+ describe('with input invisible on component render', () => {
+ let el;
+
+ beforeEach(() => {
+ setFixtures('<div id="container" style="display: none;"><input id="inputel"/></div>');
+ el = document.querySelector('#inputel');
+
+ window.IntersectionObserver = class {
+ observe = jest.fn();
+ };
+ });
+
+ afterEach(() => {
+ delete window.IntersectionObserver;
+ });
+
+ it('should bind IntersectionObserver on input element', () => {
+ jest.spyOn(el, 'focus').mockImplementation(() => {});
+
+ autofocusonshow.inserted(el);
+
+ expect(el.visibilityObserver).toBeDefined();
+ expect(el.focus).not.toHaveBeenCalled();
+ });
+
+ it('should stop IntersectionObserver on input element on unbind hook', () => {
+ el.visibilityObserver = {
+ disconnect: () => {},
+ };
+ jest.spyOn(el.visibilityObserver, 'disconnect').mockImplementation(() => {});
+
+ autofocusonshow.unbind(el);
+
+ expect(el.visibilityObserver).toBeDefined();
+ expect(el.visibilityObserver.disconnect).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/directives/tooltip_spec.js b/spec/frontend/vue_shared/directives/tooltip_spec.js
new file mode 100644
index 00000000000..9d3dd3c5f75
--- /dev/null
+++ b/spec/frontend/vue_shared/directives/tooltip_spec.js
@@ -0,0 +1,98 @@
+import $ from 'jquery';
+import { mount } from '@vue/test-utils';
+import tooltip from '~/vue_shared/directives/tooltip';
+
+describe('Tooltip directive', () => {
+ let vm;
+
+ afterEach(() => {
+ if (vm) {
+ vm.$destroy();
+ }
+ });
+
+ describe('with a single tooltip', () => {
+ beforeEach(() => {
+ const wrapper = mount(
+ {
+ directives: {
+ tooltip,
+ },
+ data() {
+ return {
+ tooltip: 'some text',
+ };
+ },
+ template: '<div v-tooltip :title="tooltip"></div>',
+ },
+ { attachToDocument: true },
+ );
+
+ vm = wrapper.vm;
+ });
+
+ it('should have tooltip plugin applied', () => {
+ expect($(vm.$el).data('bs.tooltip')).toBeDefined();
+ });
+
+ it('displays the title as tooltip', () => {
+ $(vm.$el).tooltip('show');
+ jest.runOnlyPendingTimers();
+
+ const tooltipElement = document.querySelector('.tooltip-inner');
+
+ expect(tooltipElement.textContent).toContain('some text');
+ });
+
+ it('updates a visible tooltip', () => {
+ $(vm.$el).tooltip('show');
+ jest.runOnlyPendingTimers();
+
+ const tooltipElement = document.querySelector('.tooltip-inner');
+
+ vm.tooltip = 'other text';
+
+ jest.runOnlyPendingTimers();
+
+ return vm.$nextTick().then(() => {
+ expect(tooltipElement.textContent).toContain('other text');
+ });
+ });
+ });
+
+ describe('with multiple tooltips', () => {
+ beforeEach(() => {
+ const wrapper = mount(
+ {
+ directives: {
+ tooltip,
+ },
+ template: `
+ <div>
+ <div
+ v-tooltip
+ class="js-look-for-tooltip"
+ title="foo">
+ </div>
+ <div
+ v-tooltip
+ title="bar">
+ </div>
+ </div>
+ `,
+ },
+ { attachToDocument: true },
+ );
+
+ vm = wrapper.vm;
+ });
+
+ it('should have tooltip plugin applied to all instances', () => {
+ expect(
+ $(vm.$el)
+ .find('.js-look-for-tooltip')
+ .data('bs.tooltip'),
+ ).toBeDefined();
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/translate_spec.js b/spec/frontend/vue_shared/translate_spec.js
new file mode 100644
index 00000000000..42aa28a6309
--- /dev/null
+++ b/spec/frontend/vue_shared/translate_spec.js
@@ -0,0 +1,214 @@
+import { mount, createLocalVue } from '@vue/test-utils';
+import locale from '~/locale';
+import Translate from '~/vue_shared/translate';
+
+const localVue = createLocalVue();
+localVue.use(Translate);
+
+describe('Vue translate filter', () => {
+ const createTranslationMock = (key, ...translations) => {
+ locale.textdomain('app');
+
+ locale.options.locale_data = {
+ app: {
+ '': {
+ domain: 'app',
+ lang: 'vo',
+ plural_forms: 'nplurals=2; plural=(n != 1);',
+ },
+ [key]: translations,
+ },
+ };
+ };
+
+ it('translate singular text (`__`)', () => {
+ const key = 'singular';
+ const translation = 'singular_translated';
+ createTranslationMock(key, translation);
+
+ const wrapper = mount(
+ {
+ template: `
+ <span>
+ {{ __('${key}') }}
+ </span>
+ `,
+ },
+ { localVue },
+ );
+
+ expect(wrapper.text()).toBe(translation);
+ });
+
+ it('translate plural text (`n__`) without any substituting text', () => {
+ const key = 'plural';
+ const translationPlural = 'plural_multiple translation';
+ createTranslationMock(key, 'plural_singular translation', translationPlural);
+
+ const wrapper = mount(
+ {
+ template: `
+ <span>
+ {{ n__('${key}', 'plurals', 2) }}
+ </span>
+ `,
+ },
+ { localVue },
+ );
+
+ expect(wrapper.text()).toBe(translationPlural);
+ });
+
+ describe('translate plural text (`n__`) with substituting %d', () => {
+ const key = '%d day';
+
+ beforeEach(() => {
+ createTranslationMock(key, '%d singular translated', '%d plural translated');
+ });
+
+ it('and n === 1', () => {
+ const wrapper = mount(
+ {
+ template: `
+ <span>
+ {{ n__('${key}', '%d days', 1) }}
+ </span>
+ `,
+ },
+ { localVue },
+ );
+
+ expect(wrapper.text()).toBe('1 singular translated');
+ });
+
+ it('and n > 1', () => {
+ const wrapper = mount(
+ {
+ template: `
+ <span>
+ {{ n__('${key}', '%d days', 2) }}
+ </span>
+ `,
+ },
+ { localVue },
+ );
+
+ expect(wrapper.text()).toBe('2 plural translated');
+ });
+ });
+
+ describe('translates text with context `s__`', () => {
+ const key = 'Context|Foobar';
+ const translation = 'Context|Foobar translated';
+ const expectation = 'Foobar translated';
+
+ beforeEach(() => {
+ createTranslationMock(key, translation);
+ });
+
+ it('and using two parameters', () => {
+ const wrapper = mount(
+ {
+ template: `
+ <span>
+ {{ s__('Context', 'Foobar') }}
+ </span>
+ `,
+ },
+ { localVue },
+ );
+
+ expect(wrapper.text()).toBe(expectation);
+ });
+
+ it('and using the pipe syntax', () => {
+ const wrapper = mount(
+ {
+ template: `
+ <span>
+ {{ s__('${key}') }}
+ </span>
+ `,
+ },
+ { localVue },
+ );
+
+ expect(wrapper.text()).toBe(expectation);
+ });
+ });
+
+ it('translate multi line text', () => {
+ const translation = 'multiline string translated';
+ createTranslationMock('multiline string', translation);
+
+ const wrapper = mount(
+ {
+ template: `
+ <span>
+ {{ __(\`
+ multiline
+ string
+ \`) }}
+ </span>
+ `,
+ },
+ { localVue },
+ );
+
+ expect(wrapper.text()).toBe(translation);
+ });
+
+ it('translate pluralized multi line text', () => {
+ const translation = 'multiline string plural';
+
+ createTranslationMock('multiline string', 'multiline string singular', translation);
+
+ const wrapper = mount(
+ {
+ template: `
+ <span>
+ {{ n__(
+ \`
+ multiline
+ string
+ \`,
+ \`
+ multiline
+ strings
+ \`,
+ 2
+ ) }}
+ </span>
+ `,
+ },
+ { localVue },
+ );
+
+ expect(wrapper.text()).toBe(translation);
+ });
+
+ it('translate pluralized multi line text with context', () => {
+ const translation = 'multiline string with context';
+
+ createTranslationMock('Context| multiline string', translation);
+
+ const wrapper = mount(
+ {
+ template: `
+ <span>
+ {{ s__(
+ \`
+ Context|
+ multiline
+ string
+ \`
+ ) }}
+ </span>
+ `,
+ },
+ { localVue },
+ );
+
+ expect(wrapper.text()).toBe(translation);
+ });
+});