diff options
Diffstat (limited to 'spec/frontend/vue_shared/components/filtered_search_bar')
6 files changed, 517 insertions, 11 deletions
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 index 05508d14209..73dbecadd89 100644 --- 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 @@ -1,4 +1,4 @@ -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, mount } from '@vue/test-utils'; import { GlFilteredSearch, GlButtonGroup, @@ -16,13 +16,16 @@ import RecentSearchesService from '~/filtered_search/services/recent_searches_se import { mockAvailableTokens, mockSortOptions, mockHistoryItems } from './mock_data'; const createComponent = ({ + shallow = true, namespace = 'gitlab-org/gitlab-test', recentSearchesStorageKey = 'requirements', tokens = mockAvailableTokens, - sortOptions = mockSortOptions, + sortOptions, searchInputPlaceholder = 'Filter requirements', -} = {}) => - shallowMount(FilteredSearchBarRoot, { +} = {}) => { + const mountMethod = shallow ? shallowMount : mount; + + return mountMethod(FilteredSearchBarRoot, { propsData: { namespace, recentSearchesStorageKey, @@ -31,12 +34,13 @@ const createComponent = ({ searchInputPlaceholder, }, }); +}; describe('FilteredSearchBarRoot', () => { let wrapper; beforeEach(() => { - wrapper = createComponent(); + wrapper = createComponent({ sortOptions: mockSortOptions }); }); afterEach(() => { @@ -44,23 +48,38 @@ describe('FilteredSearchBarRoot', () => { }); describe('data', () => { - it('initializes `filterValue`, `selectedSortOption` and `selectedSortDirection` data props', () => { + it('initializes `filterValue`, `selectedSortOption` and `selectedSortDirection` data props and displays the sort dropdown', () => { expect(wrapper.vm.filterValue).toEqual([]); expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[0].sortDirection.descending); expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.descending); + expect(wrapper.contains(GlButtonGroup)).toBe(true); + expect(wrapper.contains(GlButton)).toBe(true); + expect(wrapper.contains(GlDropdown)).toBe(true); + expect(wrapper.contains(GlDropdownItem)).toBe(true); + }); + + it('does not initialize `selectedSortOption` and `selectedSortDirection` when `sortOptions` is not applied and hides the sort dropdown', () => { + const wrapperNoSort = createComponent(); + + expect(wrapperNoSort.vm.filterValue).toEqual([]); + expect(wrapperNoSort.vm.selectedSortOption).toBe(undefined); + expect(wrapperNoSort.contains(GlButtonGroup)).toBe(false); + expect(wrapperNoSort.contains(GlButton)).toBe(false); + expect(wrapperNoSort.contains(GlDropdown)).toBe(false); + expect(wrapperNoSort.contains(GlDropdownItem)).toBe(false); }); }); describe('computed', () => { describe('tokenSymbols', () => { it('returns a map containing type and symbols from `tokens` prop', () => { - expect(wrapper.vm.tokenSymbols).toEqual({ author_username: '@' }); + expect(wrapper.vm.tokenSymbols).toEqual({ author_username: '@', label_name: '~' }); }); }); describe('tokenTitles', () => { it('returns a map containing type and title from `tokens` prop', () => { - expect(wrapper.vm.tokenTitles).toEqual({ author_username: 'Author' }); + expect(wrapper.vm.tokenTitles).toEqual({ author_username: 'Author', label_name: 'Label' }); }); }); @@ -99,6 +118,29 @@ describe('FilteredSearchBarRoot', () => { expect(wrapper.vm.sortDirectionTooltip).toBe('Sort direction: Descending'); }); }); + + describe('filteredRecentSearches', () => { + it('returns array of recent searches filtering out any string type (unsupported) items', async () => { + wrapper.setData({ + recentSearches: [{ foo: 'bar' }, 'foo'], + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.filteredRecentSearches).toHaveLength(1); + expect(wrapper.vm.filteredRecentSearches[0]).toEqual({ foo: 'bar' }); + }); + + it('returns undefined when recentSearchesStorageKey prop is not set on component', async () => { + wrapper.setProps({ + recentSearchesStorageKey: '', + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.filteredRecentSearches).not.toBeDefined(); + }); + }); }); describe('watchers', () => { @@ -139,6 +181,46 @@ describe('FilteredSearchBarRoot', () => { }); }); + describe('removeQuotesEnclosure', () => { + const mockFilters = [ + { + type: 'author_username', + value: { + data: 'root', + operator: '=', + }, + }, + { + type: 'label_name', + value: { + data: '"Documentation Update"', + operator: '=', + }, + }, + 'foo', + ]; + + it('returns filter array with unescaped strings for values which have spaces', () => { + expect(wrapper.vm.removeQuotesEnclosure(mockFilters)).toEqual([ + { + type: 'author_username', + value: { + data: 'root', + operator: '=', + }, + }, + { + type: 'label_name', + value: { + data: 'Documentation Update', + operator: '=', + }, + }, + 'foo', + ]); + }); + }); + describe('handleSortOptionClick', () => { it('emits component event `onSort` with selected sort by value', () => { wrapper.vm.handleSortOptionClick(mockSortOptions[1]); @@ -172,9 +254,12 @@ describe('FilteredSearchBarRoot', () => { describe('handleHistoryItemSelected', () => { it('emits `onFilter` event with provided filters param', () => { + jest.spyOn(wrapper.vm, 'removeQuotesEnclosure'); + wrapper.vm.handleHistoryItemSelected(mockHistoryItems[0]); expect(wrapper.emitted('onFilter')[0]).toEqual([mockHistoryItems[0]]); + expect(wrapper.vm.removeQuotesEnclosure).toHaveBeenCalledWith(mockHistoryItems[0]); }); }); @@ -233,10 +318,21 @@ describe('FilteredSearchBarRoot', () => { }); }); + it('calls `blurSearchInput` method to remove focus from filter input field', () => { + jest.spyOn(wrapper.vm, 'blurSearchInput'); + + wrapper.find(GlFilteredSearch).vm.$emit('submit', mockFilters); + + expect(wrapper.vm.blurSearchInput).toHaveBeenCalled(); + }); + it('emits component event `onFilter` with provided filters param', () => { + jest.spyOn(wrapper.vm, 'removeQuotesEnclosure'); + wrapper.vm.handleFilterSubmit(mockFilters); expect(wrapper.emitted('onFilter')[0]).toEqual([mockFilters]); + expect(wrapper.vm.removeQuotesEnclosure).toHaveBeenCalledWith(mockFilters); }); }); }); @@ -260,13 +356,28 @@ describe('FilteredSearchBarRoot', () => { expect(glFilteredSearchEl.props('historyItems')).toEqual(mockHistoryItems); }); + it('renders search history items dropdown with formatting done using token symbols', async () => { + const wrapperFullMount = createComponent({ sortOptions: mockSortOptions, shallow: false }); + wrapperFullMount.vm.recentSearchesStore.addRecentSearch(mockHistoryItems[0]); + + await wrapperFullMount.vm.$nextTick(); + + const searchHistoryItemsEl = wrapperFullMount.findAll( + '.gl-search-box-by-click-menu .gl-search-box-by-click-history-item', + ); + + expect(searchHistoryItemsEl.at(0).text()).toBe('Author := @tobyLabel := ~Bug"duo"'); + + wrapperFullMount.destroy(); + }); + 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', () => { + it('renders sort dropdown items', () => { const dropdownItemsEl = wrapper.findAll(GlDropdownItem); expect(dropdownItemsEl).toHaveLength(mockSortOptions.length); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js new file mode 100644 index 00000000000..a857f84adf1 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js @@ -0,0 +1,19 @@ +import * as filteredSearchUtils from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; + +describe('Filtered Search Utils', () => { + describe('stripQuotes', () => { + it.each` + inputValue | outputValue + ${'"Foo Bar"'} | ${'Foo Bar'} + ${"'Foo Bar'"} | ${'Foo Bar'} + ${'FooBar'} | ${'FooBar'} + ${"Foo'Bar"} | ${"Foo'Bar"} + ${'Foo"Bar'} | ${'Foo"Bar'} + `( + 'returns string $outputValue when called with string $inputValue', + ({ inputValue, outputValue }) => { + expect(filteredSearchUtils.stripQuotes(inputValue)).toBe(outputValue); + }, + ); + }); +}); 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 index 7e28c4e11e1..dcccb1f49b6 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -1,5 +1,8 @@ +import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; import Api from '~/api'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; +import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; export const mockAuthor1 = { id: 1, @@ -30,6 +33,28 @@ export const mockAuthor3 = { export const mockAuthors = [mockAuthor1, mockAuthor2, mockAuthor3]; +export const mockRegularMilestone = { + id: 1, + name: '4.0', + title: '4.0', +}; + +export const mockEscapedMilestone = { + id: 3, + name: '5.0 RC1', + title: '5.0 RC1', +}; + +export const mockMilestones = [ + { + id: 2, + name: '5.0', + title: '5.0', + }, + mockRegularMilestone, + mockEscapedMilestone, +]; + export const mockAuthorToken = { type: 'author_username', icon: 'user', @@ -42,7 +67,29 @@ export const mockAuthorToken = { fetchAuthors: Api.projectUsers.bind(Api), }; -export const mockAvailableTokens = [mockAuthorToken]; +export const mockLabelToken = { + type: 'label_name', + icon: 'labels', + title: 'Label', + unique: false, + symbol: '~', + token: LabelToken, + operators: [{ value: '=', description: 'is', default: 'true' }], + fetchLabels: () => Promise.resolve(mockLabels), +}; + +export const mockMilestoneToken = { + type: 'milestone_title', + icon: 'clock', + title: 'Milestone', + unique: true, + symbol: '%', + token: MilestoneToken, + operators: [{ value: '=', description: 'is', default: 'true' }], + fetchMilestones: () => Promise.resolve({ data: mockMilestones }), +}; + +export const mockAvailableTokens = [mockAuthorToken, mockLabelToken]; export const mockHistoryItems = [ [ @@ -53,6 +100,13 @@ export const mockHistoryItems = [ operator: '=', }, }, + { + type: 'label_name', + value: { + data: 'Bug', + operator: '=', + }, + }, 'duo', ], [ 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 index 45294096eda..160febf9d06 100644 --- 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 @@ -4,7 +4,7 @@ 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 { deprecatedCreateFlash as createFlash } from '~/flash'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import { mockAuthorToken, mockAuthors } from '../mock_data'; diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js new file mode 100644 index 00000000000..0e60ee99327 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js @@ -0,0 +1,170 @@ +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 { + mockRegularLabel, + mockLabels, +} from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; +import axios from '~/lib/utils/axios_utils'; + +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; + +import { mockLabelToken } from '../mock_data'; + +jest.mock('~/flash'); + +const createComponent = ({ config = mockLabelToken, value = { data: '' }, active = false } = {}) => + mount(LabelToken, { + propsData: { + config, + value, + active, + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + }, + stubs: { + Portal: { + template: '<div><slot></slot></div>', + }, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, + }, + }); + +describe('LabelToken', () => { + let mock; + let wrapper; + + beforeEach(() => { + mock = new MockAdapter(axios); + wrapper = createComponent(); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + describe('computed', () => { + beforeEach(async () => { + // Label title with spaces is always enclosed in quotations by component. + wrapper = createComponent({ value: { data: `"${mockRegularLabel.title}"` } }); + + wrapper.setData({ + labels: mockLabels, + }); + + await wrapper.vm.$nextTick(); + }); + + describe('currentValue', () => { + it('returns lowercase string for `value.data`', () => { + expect(wrapper.vm.currentValue).toBe('"foo label"'); + }); + }); + + describe('activeLabel', () => { + it('returns object for currently present `value.data`', () => { + expect(wrapper.vm.activeLabel).toEqual(mockRegularLabel); + }); + }); + + describe('containerStyle', () => { + it('returns object containing `backgroundColor` and `color` properties based on `activeLabel` value', () => { + expect(wrapper.vm.containerStyle).toEqual({ + backgroundColor: mockRegularLabel.color, + color: mockRegularLabel.textColor, + }); + }); + + it('returns empty object when `activeLabel` is not set', async () => { + wrapper.setData({ + labels: [], + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.containerStyle).toEqual({}); + }); + }); + }); + + describe('methods', () => { + describe('fetchLabelBySearchTerm', () => { + it('calls `config.fetchLabels` with provided searchTerm param', () => { + jest.spyOn(wrapper.vm.config, 'fetchLabels'); + + wrapper.vm.fetchLabelBySearchTerm('foo'); + + expect(wrapper.vm.config.fetchLabels).toHaveBeenCalledWith('foo'); + }); + + it('sets response to `labels` when request is succesful', () => { + jest.spyOn(wrapper.vm.config, 'fetchLabels').mockResolvedValue(mockLabels); + + wrapper.vm.fetchLabelBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(wrapper.vm.labels).toEqual(mockLabels); + }); + }); + + it('calls `createFlash` with flash error message when request fails', () => { + jest.spyOn(wrapper.vm.config, 'fetchLabels').mockRejectedValue({}); + + wrapper.vm.fetchLabelBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(createFlash).toHaveBeenCalledWith('There was a problem fetching labels.'); + }); + }); + + it('sets `loading` to false when request completes', () => { + jest.spyOn(wrapper.vm.config, 'fetchLabels').mockRejectedValue({}); + + wrapper.vm.fetchLabelBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(wrapper.vm.loading).toBe(false); + }); + }); + }); + }); + + describe('template', () => { + beforeEach(async () => { + wrapper = createComponent({ value: { data: `"${mockRegularLabel.title}"` } }); + + wrapper.setData({ + labels: mockLabels, + }); + + await 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', () => { + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + + expect(tokenSegments).toHaveLength(3); // Label, =, "Foo Label" + expect(tokenSegments.at(2).text()).toBe(`~${mockRegularLabel.title}`); // "Foo Label" + expect( + tokenSegments + .at(2) + .find('.gl-token') + .attributes('style'), + ).toBe('background-color: rgb(186, 218, 85); color: rgb(255, 255, 255);'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js new file mode 100644 index 00000000000..de893bf44c8 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js @@ -0,0 +1,152 @@ +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 MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; + +import { + mockMilestoneToken, + mockMilestones, + mockRegularMilestone, + mockEscapedMilestone, +} from '../mock_data'; + +jest.mock('~/flash'); + +const createComponent = ({ + config = mockMilestoneToken, + value = { data: '' }, + active = false, +} = {}) => + mount(MilestoneToken, { + propsData: { + config, + value, + active, + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + }, + stubs: { + Portal: { + template: '<div><slot></slot></div>', + }, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, + }, + }); + +describe('MilestoneToken', () => { + let mock; + let wrapper; + + beforeEach(() => { + mock = new MockAdapter(axios); + wrapper = createComponent(); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + describe('computed', () => { + beforeEach(async () => { + // Milestone title with spaces is always enclosed in quotations by component. + wrapper = createComponent({ value: { data: `"${mockEscapedMilestone.title}"` } }); + + wrapper.setData({ + milestones: mockMilestones, + }); + + await wrapper.vm.$nextTick(); + }); + + describe('currentValue', () => { + it('returns lowercase string for `value.data`', () => { + expect(wrapper.vm.currentValue).toBe('"5.0 rc1"'); + }); + }); + + describe('activeMilestone', () => { + it('returns object for currently present `value.data`', () => { + expect(wrapper.vm.activeMilestone).toEqual(mockEscapedMilestone); + }); + }); + }); + + describe('methods', () => { + describe('fetchMilestoneBySearchTerm', () => { + it('calls `config.fetchMilestones` with provided searchTerm param', () => { + jest.spyOn(wrapper.vm.config, 'fetchMilestones'); + + wrapper.vm.fetchMilestoneBySearchTerm('foo'); + + expect(wrapper.vm.config.fetchMilestones).toHaveBeenCalledWith('foo'); + }); + + it('sets response to `milestones` when request is successful', () => { + jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockResolvedValue({ + data: mockMilestones, + }); + + wrapper.vm.fetchMilestoneBySearchTerm(); + + return waitForPromises().then(() => { + expect(wrapper.vm.milestones).toEqual(mockMilestones); + }); + }); + + it('calls `createFlash` with flash error message when request fails', () => { + jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockRejectedValue({}); + + wrapper.vm.fetchMilestoneBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(createFlash).toHaveBeenCalledWith('There was a problem fetching milestones.'); + }); + }); + + it('sets `loading` to false when request completes', () => { + jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockRejectedValue({}); + + wrapper.vm.fetchMilestoneBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(wrapper.vm.loading).toBe(false); + }); + }); + }); + }); + + describe('template', () => { + beforeEach(async () => { + wrapper = createComponent({ value: { data: `"${mockRegularMilestone.title}"` } }); + + wrapper.setData({ + milestones: mockMilestones, + }); + + await 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', () => { + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + + expect(tokenSegments).toHaveLength(3); // Milestone, =, '%"4.0"' + expect(tokenSegments.at(2).text()).toBe(`%"${mockRegularMilestone.title}"`); // "4.0 RC1" + }); + }); +}); |