diff options
Diffstat (limited to 'spec/frontend/vue_shared/components')
23 files changed, 982 insertions, 235 deletions
diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js index 93b59800c27..441e21ee905 100644 --- a/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js +++ b/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js @@ -84,15 +84,15 @@ describe('LabelsSelectRoot', () => { }); describe('if the variant is `sidebar`', () => { - beforeEach(() => { + it('renders SidebarEditableItem component', () => { createComponent(); - }); - it('renders SidebarEditableItem component', () => { expect(findSidebarEditableItem().exists()).toBe(true); }); it('renders correct props for the SidebarEditableItem component', () => { + createComponent(); + expect(findSidebarEditableItem().props()).toMatchObject({ title: wrapper.vm.$options.i18n.widgetTitle, canEdit: defaultProps.allowEdit, @@ -135,7 +135,7 @@ describe('LabelsSelectRoot', () => { it('handles DropdownContents setColor', () => { findDropdownContents().vm.$emit('setColor', color); - expect(wrapper.emitted('updateSelectedColor')).toEqual([[color]]); + expect(wrapper.emitted('updateSelectedColor')).toEqual([[{ color }]]); }); }); @@ -157,20 +157,24 @@ describe('LabelsSelectRoot', () => { createComponent({ propsData: { iid: undefined } }); findDropdownContents().vm.$emit('setColor', color); - expect(wrapper.emitted('updateSelectedColor')).toEqual([[color]]); + expect(wrapper.emitted('updateSelectedColor')).toEqual([[{ color }]]); }); describe('when updating color for epic', () => { - beforeEach(() => { + const setup = () => { createComponent(); findDropdownContents().vm.$emit('setColor', color); - }); + }; it('sets the loading state', () => { + setup(); + expect(findSidebarEditableItem().props('loading')).toBe(true); }); it('updates color correctly after successful mutation', async () => { + setup(); + await waitForPromises(); expect(findDropdownValue().props('selectedColor').color).toEqual( updateColorMutationResponse.data.updateIssuableColor.issuable.color, diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js index 74f50b878e2..ee4d3a2630a 100644 --- a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js +++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js @@ -1,57 +1,30 @@ -import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { GlDropdown } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { DROPDOWN_VARIANT } from '~/vue_shared/components/color_select_dropdown/constants'; import DropdownContents from '~/vue_shared/components/color_select_dropdown/dropdown_contents.vue'; import DropdownContentsColorView from '~/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue'; +import DropdownHeader from '~/vue_shared/components/color_select_dropdown/dropdown_header.vue'; import { color } from './mock_data'; -const showDropdown = jest.fn(); -const focusInput = jest.fn(); - const defaultProps = { dropdownTitle: '', selectedColor: color, - dropdownButtonText: '', + dropdownButtonText: 'Pick a color', variant: '', isVisible: false, }; -const GlDropdownStub = { - template: ` - <div> - <slot name="header"></slot> - <slot></slot> - </div> - `, - methods: { - show: showDropdown, - hide: jest.fn(), - }, -}; - -const DropdownHeaderStub = { - template: ` - <div>Hello, I am a header</div> - `, - methods: { - focusInput, - }, -}; - describe('DropdownContent', () => { let wrapper; const createComponent = ({ propsData = {} } = {}) => { - wrapper = shallowMount(DropdownContents, { + wrapper = mountExtended(DropdownContents, { propsData: { ...defaultProps, ...propsData, }, - stubs: { - GlDropdown: GlDropdownStub, - DropdownHeader: DropdownHeaderStub, - }, }); }; @@ -60,16 +33,17 @@ describe('DropdownContent', () => { }); const findColorView = () => wrapper.findComponent(DropdownContentsColorView); - const findDropdownHeader = () => wrapper.findComponent(DropdownHeaderStub); - const findDropdown = () => wrapper.findComponent(GlDropdownStub); + const findDropdownHeader = () => wrapper.findComponent(DropdownHeader); + const findDropdown = () => wrapper.findComponent(GlDropdown); it('calls dropdown `show` method on `isVisible` prop change', async () => { createComponent(); + const spy = jest.spyOn(wrapper.vm.$refs.dropdown, 'show'); await wrapper.setProps({ isVisible: true, }); - expect(showDropdown).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(1); }); it('does not emit `setColor` event on dropdown hide if color did not change', () => { @@ -110,4 +84,12 @@ describe('DropdownContent', () => { expect(findDropdownHeader().exists()).toBe(true); }); + + it('handles no selected color', () => { + createComponent({ propsData: { selectedColor: {} } }); + + expect(wrapper.findByTestId('fallback-button-text').text()).toEqual( + defaultProps.dropdownButtonText, + ); + }); }); diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js index f22592dd604..5bbdb136353 100644 --- a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js +++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js @@ -33,7 +33,7 @@ describe('DropdownValue', () => { it.each` index | cssClass - ${0} | ${['gl-font-base', 'gl-line-height-24']} + ${0} | ${[]} ${1} | ${['hide-collapsed']} `( 'passes correct props to the ColorItem with CSS class `$cssClass`', diff --git a/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js b/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js index e3d8bfd22ca..79001b9282f 100644 --- a/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js +++ b/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js @@ -1,7 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import DeployBoardInstance from '~/vue_shared/components/deployment_instance.vue'; -import { folder } from './mock_data'; describe('Deploy Board Instance', () => { let wrapper; @@ -13,7 +12,6 @@ describe('Deploy Board Instance', () => { ...props, }, provide: { - glFeatures: { monitorLogging: true }, ...provide, }, }); @@ -25,7 +23,6 @@ describe('Deploy Board Instance', () => { it('should render a div with the correct css status and tooltip data', () => { wrapper = createComponent({ - logsPath: folder.logs_path, tooltipText: 'This is a pod', }); @@ -43,17 +40,6 @@ describe('Deploy Board Instance', () => { expect(wrapper.classes('deployment-instance-deploying')).toBe(true); expect(wrapper.attributes('title')).toEqual(''); }); - - it('should have a log path computed with a pod name as a parameter', () => { - wrapper = createComponent({ - logsPath: folder.logs_path, - podName: 'tanuki-1', - }); - - expect(wrapper.vm.computedLogPath).toEqual( - '/root/review-app/-/logs?environment_name=foo&pod_name=tanuki-1', - ); - }); }); describe('as a canary deployment', () => { @@ -76,46 +62,10 @@ describe('Deploy Board Instance', () => { wrapper.destroy(); }); - it('should not be a link without a logsPath prop', async () => { - wrapper = createComponent({ - stable: false, - logsPath: '', - }); - - await nextTick(); - expect(wrapper.vm.computedLogPath).toBeNull(); - expect(wrapper.vm.isLink).toBeFalsy(); - }); - - it('should render a link without href if path is not passed', () => { - wrapper = createComponent(); - - expect(wrapper.attributes('href')).toBeUndefined(); - }); - it('should not have a tooltip', () => { wrapper = createComponent(); expect(wrapper.attributes('title')).toEqual(''); }); }); - - describe(':monitor_logging feature flag', () => { - afterEach(() => { - wrapper.destroy(); - }); - - it.each` - flagState | logsState | expected - ${true} | ${'shows'} | ${'/root/review-app/-/logs?environment_name=foo&pod_name=tanuki-1'} - ${false} | ${'hides'} | ${undefined} - `('$logsState log link when flag state is $flagState', async ({ flagState, expected }) => { - wrapper = createComponent( - { logsPath: folder.logs_path, podName: 'tanuki-1' }, - { glFeatures: { monitorLogging: flagState } }, - ); - - expect(wrapper.attributes('href')).toEqual(expected); - }); - }); }); diff --git a/spec/frontend/vue_shared/components/deployment_instance/mock_data.js b/spec/frontend/vue_shared/components/deployment_instance/mock_data.js index 6618c57948c..098787cd1b4 100644 --- a/spec/frontend/vue_shared/components/deployment_instance/mock_data.js +++ b/spec/frontend/vue_shared/components/deployment_instance/mock_data.js @@ -140,5 +140,4 @@ export const folder = { created_at: '2017-02-01T19:42:18.400Z', updated_at: '2017-02-01T19:42:18.400Z', rollout_status: {}, - logs_path: '/root/review-app/-/logs?environment_name=foo', }; diff --git a/spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js b/spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js index d0fa8b8dacb..16f924b44d8 100644 --- a/spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js +++ b/spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js @@ -1,11 +1,9 @@ import { mount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import { compileToFunctions } from 'vue-template-compiler'; - +import { nextTick } from 'vue'; import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants'; -import imageDiffViewer from '~/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue'; +import ImageDiffViewer from '~/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue'; -describe('ImageDiffViewer', () => { +describe('ImageDiffViewer component', () => { const requiredProps = { diffMode: 'replaced', newPath: GREEN_BOX_IMAGE_URL, @@ -17,15 +15,12 @@ describe('ImageDiffViewer', () => { newSize: 1024, }; let wrapper; - let vm; - function createComponent(props) { - const ImageDiffViewer = Vue.extend(imageDiffViewer); - wrapper = mount(ImageDiffViewer, { propsData: props }); - vm = wrapper.vm; - } + const createComponent = (props, slots) => { + wrapper = mount(ImageDiffViewer, { propsData: props, slots }); + }; - const triggerEvent = (eventName, el = vm.$el, clientX = 0) => { + const triggerEvent = (eventName, el = wrapper.$el, clientX = 0) => { const event = new MouseEvent(eventName, { bubbles: true, cancelable: true, @@ -51,128 +46,76 @@ describe('ImageDiffViewer', () => { wrapper.destroy(); }); - it('renders image diff for replaced', async () => { - createComponent({ ...allProps }); - - await nextTick(); - const metaInfoElements = vm.$el.querySelectorAll('.image-info'); - - expect(vm.$el.querySelector('.added img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL); - - expect(vm.$el.querySelector('.deleted img').getAttribute('src')).toBe(RED_BOX_IMAGE_URL); - - expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe('2-up'); - expect(vm.$el.querySelector('.view-modes-menu li:nth-child(2)').textContent.trim()).toBe( - 'Swipe', - ); - - expect(vm.$el.querySelector('.view-modes-menu li:nth-child(3)').textContent.trim()).toBe( - 'Onion skin', - ); - - expect(metaInfoElements.length).toBe(2); - expect(metaInfoElements[0]).toHaveText('2.00 KiB'); - expect(metaInfoElements[1]).toHaveText('1.00 KiB'); + it('renders image diff for replaced', () => { + createComponent(allProps); + const metaInfoElements = wrapper.findAll('.image-info'); + + expect(wrapper.find('.added img').attributes('src')).toBe(GREEN_BOX_IMAGE_URL); + expect(wrapper.find('.deleted img').attributes('src')).toBe(RED_BOX_IMAGE_URL); + expect(wrapper.find('.view-modes-menu li.active').text()).toBe('2-up'); + expect(wrapper.find('.view-modes-menu li:nth-child(2)').text()).toBe('Swipe'); + expect(wrapper.find('.view-modes-menu li:nth-child(3)').text()).toBe('Onion skin'); + expect(metaInfoElements).toHaveLength(2); + expect(metaInfoElements.at(0).text()).toBe('2.00 KiB'); + expect(metaInfoElements.at(1).text()).toBe('1.00 KiB'); }); - it('renders image diff for new', async () => { + it('renders image diff for new', () => { createComponent({ ...allProps, diffMode: 'new', oldPath: '' }); - await nextTick(); - - const metaInfoElement = vm.$el.querySelector('.image-info'); - - expect(vm.$el.querySelector('.added img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL); - expect(metaInfoElement).toHaveText('1.00 KiB'); + expect(wrapper.find('.added img').attributes('src')).toBe(GREEN_BOX_IMAGE_URL); + expect(wrapper.find('.image-info').text()).toBe('1.00 KiB'); }); - it('renders image diff for deleted', async () => { + it('renders image diff for deleted', () => { createComponent({ ...allProps, diffMode: 'deleted', newPath: '' }); - await nextTick(); - - const metaInfoElement = vm.$el.querySelector('.image-info'); - - expect(vm.$el.querySelector('.deleted img').getAttribute('src')).toBe(RED_BOX_IMAGE_URL); - expect(metaInfoElement).toHaveText('2.00 KiB'); + expect(wrapper.find('.deleted img').attributes('src')).toBe(RED_BOX_IMAGE_URL); + expect(wrapper.find('.image-info').text()).toBe('2.00 KiB'); }); - it('renders image diff for renamed', async () => { - vm = new Vue({ - components: { - imageDiffViewer, - }, - data() { - return { - ...allProps, - diffMode: 'renamed', - }; - }, - ...compileToFunctions(` - <image-diff-viewer - :diff-mode="diffMode" - :new-path="newPath" - :old-path="oldPath" - :new-size="newSize" - :old-size="oldSize" - > - <template #image-overlay> - <span class="overlay">test</span> - </template> - </image-diff-viewer> - `), - }).$mount(); - - await nextTick(); - - const metaInfoElement = vm.$el.querySelector('.image-info'); - - expect(vm.$el.querySelector('img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL); - expect(vm.$el.querySelector('.overlay')).not.toBe(null); - - expect(metaInfoElement).toHaveText('2.00 KiB'); + it('renders image diff for renamed', () => { + createComponent( + { ...allProps, diffMode: 'renamed' }, + { 'image-overlay': '<span class="overlay">test</span>' }, + ); + + expect(wrapper.find('img').attributes('src')).toBe(GREEN_BOX_IMAGE_URL); + expect(wrapper.find('.overlay').exists()).toBe(true); + expect(wrapper.find('.image-info').text()).toBe('2.00 KiB'); }); describe('swipeMode', () => { beforeEach(() => { - createComponent({ ...requiredProps }); - - return nextTick(); + createComponent(requiredProps); }); it('switches to Swipe Mode', async () => { - vm.$el.querySelector('.view-modes-menu li:nth-child(2)').click(); + await wrapper.find('.view-modes-menu li:nth-child(2)').trigger('click'); - await nextTick(); - expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe('Swipe'); + expect(wrapper.find('.view-modes-menu li.active').text()).toBe('Swipe'); }); }); describe('onionSkin', () => { beforeEach(() => { createComponent({ ...requiredProps }); - - return nextTick(); }); it('switches to Onion Skin Mode', async () => { - vm.$el.querySelector('.view-modes-menu li:nth-child(3)').click(); + await wrapper.find('.view-modes-menu li:nth-child(3)').trigger('click'); - await nextTick(); - expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe( - 'Onion skin', - ); + expect(wrapper.find('.view-modes-menu li.active').text()).toBe('Onion skin'); }); it('has working drag handler', async () => { - vm.$el.querySelector('.view-modes-menu li:nth-child(3)').click(); + await wrapper.find('.view-modes-menu li:nth-child(3)').trigger('click'); + dragSlider(wrapper.find('.dragger').element, document, 20); await nextTick(); - dragSlider(vm.$el.querySelector('.dragger'), document, 20); - await nextTick(); - expect(vm.$el.querySelector('.dragger').style.left).toBe('20px'); - expect(vm.$el.querySelector('.added.frame').style.opacity).toBe('0.2'); + expect(wrapper.find('.dragger').attributes('style')).toBe('left: 20px;'); + expect(wrapper.find('.added.frame').attributes('style')).toBe('opacity: 0.2;'); }); }); }); 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 e3e2ef5610d..86d1f21fd04 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 @@ -8,6 +8,8 @@ import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji 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'; import ReleaseToken from '~/vue_shared/components/filtered_search_bar/tokens/release_token.vue'; +import CrmContactToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue'; +import CrmOrganizationToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue'; export const mockAuthor1 = { id: 1, @@ -62,6 +64,128 @@ export const mockMilestones = [ mockEscapedMilestone, ]; +export const mockCrmContacts = [ + { + id: 'gid://gitlab/CustomerRelations::Contact/1', + firstName: 'John', + lastName: 'Smith', + email: 'john@smith.com', + }, + { + id: 'gid://gitlab/CustomerRelations::Contact/2', + firstName: 'Andy', + lastName: 'Green', + email: 'andy@green.net', + }, +]; + +export const mockCrmOrganizations = [ + { + id: 'gid://gitlab/CustomerRelations::Organization/1', + name: 'First Org Ltd.', + }, + { + id: 'gid://gitlab/CustomerRelations::Organization/2', + name: 'Organizer S.p.a.', + }, +]; + +export const mockProjectCrmContactsQueryResponse = { + data: { + project: { + __typename: 'Project', + id: 1, + group: { + __typename: 'Group', + id: 1, + contacts: { + __typename: 'CustomerRelationsContactConnection', + nodes: [ + { + __typename: 'CustomerRelationsContact', + ...mockCrmContacts[0], + }, + { + __typename: 'CustomerRelationsContact', + ...mockCrmContacts[1], + }, + ], + }, + }, + }, + }, +}; + +export const mockProjectCrmOrganizationsQueryResponse = { + data: { + project: { + __typename: 'Project', + id: 1, + group: { + __typename: 'Group', + id: 1, + organizations: { + __typename: 'CustomerRelationsOrganizationConnection', + nodes: [ + { + __typename: 'CustomerRelationsOrganization', + ...mockCrmOrganizations[0], + }, + { + __typename: 'CustomerRelationsOrganization', + ...mockCrmOrganizations[1], + }, + ], + }, + }, + }, + }, +}; + +export const mockGroupCrmContactsQueryResponse = { + data: { + group: { + __typename: 'Group', + id: 1, + contacts: { + __typename: 'CustomerRelationsContactConnection', + nodes: [ + { + __typename: 'CustomerRelationsContact', + ...mockCrmContacts[0], + }, + { + __typename: 'CustomerRelationsContact', + ...mockCrmContacts[1], + }, + ], + }, + }, + }, +}; + +export const mockGroupCrmOrganizationsQueryResponse = { + data: { + group: { + __typename: 'Group', + id: 1, + organizations: { + __typename: 'CustomerRelationsOrganizationConnection', + nodes: [ + { + __typename: 'CustomerRelationsOrganization', + ...mockCrmOrganizations[0], + }, + { + __typename: 'CustomerRelationsOrganization', + ...mockCrmOrganizations[1], + }, + ], + }, + }, + }, +}; + export const mockEmoji1 = { name: 'thumbsup', }; @@ -134,6 +258,28 @@ export const mockReactionEmojiToken = { fetchEmojis: () => Promise.resolve(mockEmojis), }; +export const mockCrmContactToken = { + type: 'crm_contact', + title: 'Contact', + icon: 'user', + token: CrmContactToken, + isProject: false, + fullPath: 'group', + operators: OPERATOR_IS_ONLY, + unique: true, +}; + +export const mockCrmOrganizationToken = { + type: 'crm_contact', + title: 'Organization', + icon: 'user', + token: CrmOrganizationToken, + isProject: false, + fullPath: 'group', + operators: OPERATOR_IS_ONLY, + unique: true, +}; + export const mockMembershipToken = { type: 'with_inherited_permissions', icon: 'group', diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js index ca8cd419d87..a0126c2bd63 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js @@ -418,8 +418,6 @@ describe('BaseToken', () => { }); it('does not emit `fetch-suggestions` event on component after a delay when component emits `input` event', async () => { - jest.useFakeTimers(); - findGlFilteredSearchToken().vm.$emit('input', { data: 'foo' }); await nextTick(); @@ -437,8 +435,6 @@ describe('BaseToken', () => { }); it('emits `fetch-suggestions` event on component after a delay when component emits `input` event', async () => { - jest.useFakeTimers(); - findGlFilteredSearchToken().vm.$emit('input', { data: 'foo' }); await nextTick(); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js new file mode 100644 index 00000000000..157e021fc60 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js @@ -0,0 +1,283 @@ +import { + GlFilteredSearchSuggestion, + GlFilteredSearchTokenSegment, + GlDropdownDivider, +} from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import CrmContactToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue'; +import searchCrmContactsQuery from '~/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql'; + +import { + mockCrmContacts, + mockCrmContactToken, + mockGroupCrmContactsQueryResponse, + mockProjectCrmContactsQueryResponse, +} from '../mock_data'; + +jest.mock('~/flash'); + +const defaultStubs = { + Portal: true, + BaseToken, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, +}; + +describe('CrmContactToken', () => { + Vue.use(VueApollo); + + let wrapper; + let fakeApollo; + + const getBaseToken = () => wrapper.findComponent(BaseToken); + + const searchGroupCrmContactsQueryHandler = jest + .fn() + .mockResolvedValue(mockGroupCrmContactsQueryResponse); + const searchProjectCrmContactsQueryHandler = jest + .fn() + .mockResolvedValue(mockProjectCrmContactsQueryResponse); + + const mountComponent = ({ + config = mockCrmContactToken, + value = { data: '' }, + active = false, + stubs = defaultStubs, + listeners = {}, + queryHandler = searchGroupCrmContactsQueryHandler, + } = {}) => { + fakeApollo = createMockApollo([[searchCrmContactsQuery, queryHandler]]); + + wrapper = mount(CrmContactToken, { + propsData: { + config, + value, + active, + cursorPosition: 'start', + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: () => 'custom-class', + }, + stubs, + listeners, + apolloProvider: fakeApollo, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + describe('methods', () => { + describe('fetchContacts', () => { + describe('for groups', () => { + beforeEach(() => { + mountComponent(); + }); + + it('calls the apollo query providing the searchString when search term is a string', async () => { + getBaseToken().vm.$emit('fetch-suggestions', 'foo'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchGroupCrmContactsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'group', + isProject: false, + searchString: 'foo', + searchIds: null, + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmContacts); + }); + + it('calls the apollo query providing the searchId when search term is a number', async () => { + getBaseToken().vm.$emit('fetch-suggestions', '5'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchGroupCrmContactsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'group', + isProject: false, + searchString: null, + searchIds: ['gid://gitlab/CustomerRelations::Contact/5'], + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmContacts); + }); + }); + + describe('for projects', () => { + beforeEach(() => { + mountComponent({ + config: { + fullPath: 'project', + isProject: true, + }, + queryHandler: searchProjectCrmContactsQueryHandler, + }); + }); + + it('calls the apollo query providing the searchString when search term is a string', async () => { + getBaseToken().vm.$emit('fetch-suggestions', 'foo'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchProjectCrmContactsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'project', + isProject: true, + searchString: 'foo', + searchIds: null, + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmContacts); + }); + + it('calls the apollo query providing the searchId when search term is a number', async () => { + getBaseToken().vm.$emit('fetch-suggestions', '5'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchProjectCrmContactsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'project', + isProject: true, + searchString: null, + searchIds: ['gid://gitlab/CustomerRelations::Contact/5'], + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmContacts); + }); + }); + + it('calls `createFlash` with flash error message when request fails', async () => { + mountComponent(); + + jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); + + getBaseToken().vm.$emit('fetch-suggestions'); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching CRM contacts.', + }); + }); + + it('sets `loading` to false when request completes', async () => { + mountComponent(); + + jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); + + getBaseToken().vm.$emit('fetch-suggestions'); + + await waitForPromises(); + + expect(getBaseToken().props('suggestionsLoading')).toBe(false); + }); + }); + }); + + describe('template', () => { + const defaultContacts = DEFAULT_NONE_ANY; + + it('renders base-token component', () => { + mountComponent({ + config: { ...mockCrmContactToken, initialContacts: mockCrmContacts }, + value: { data: '1' }, + }); + + const baseTokenEl = wrapper.find(BaseToken); + + expect(baseTokenEl.exists()).toBe(true); + expect(baseTokenEl.props()).toMatchObject({ + suggestions: mockCrmContacts, + getActiveTokenValue: wrapper.vm.getActiveContact, + }); + }); + + it.each(mockCrmContacts)('renders token item when value is selected', (contact) => { + mountComponent({ + config: { ...mockCrmContactToken, initialContacts: mockCrmContacts }, + value: { data: `${getIdFromGraphQLId(contact.id)}` }, + }); + + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + + expect(tokenSegments).toHaveLength(3); // Contact, =, Contact name + expect(tokenSegments.at(2).text()).toBe(`${contact.firstName} ${contact.lastName}`); // Contact name + }); + + it('renders provided defaultContacts as suggestions', async () => { + mountComponent({ + active: true, + config: { ...mockCrmContactToken, defaultContacts }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(defaultContacts.length); + defaultContacts.forEach((contact, index) => { + expect(suggestions.at(index).text()).toBe(contact.text); + }); + }); + + it('does not render divider when no defaultContacts', async () => { + mountComponent({ + active: true, + config: { ...mockCrmContactToken, defaultContacts: [] }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await nextTick(); + + expect(wrapper.find(GlFilteredSearchSuggestion).exists()).toBe(false); + expect(wrapper.find(GlDropdownDivider).exists()).toBe(false); + }); + + it('renders `DEFAULT_NONE_ANY` as default suggestions', () => { + mountComponent({ + active: true, + config: { ...mockCrmContactToken }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(DEFAULT_NONE_ANY.length); + DEFAULT_NONE_ANY.forEach((contact, index) => { + expect(suggestions.at(index).text()).toBe(contact.text); + }); + }); + + it('emits listeners in the base-token', () => { + const mockInput = jest.fn(); + mountComponent({ + listeners: { + input: mockInput, + }, + }); + wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]); + + expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js new file mode 100644 index 00000000000..977f8bbef61 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js @@ -0,0 +1,282 @@ +import { + GlFilteredSearchSuggestion, + GlFilteredSearchTokenSegment, + GlDropdownDivider, +} from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import CrmOrganizationToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue'; +import searchCrmOrganizationsQuery from '~/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql'; + +import { + mockCrmOrganizations, + mockCrmOrganizationToken, + mockGroupCrmOrganizationsQueryResponse, + mockProjectCrmOrganizationsQueryResponse, +} from '../mock_data'; + +jest.mock('~/flash'); + +const defaultStubs = { + Portal: true, + BaseToken, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, +}; + +describe('CrmOrganizationToken', () => { + Vue.use(VueApollo); + + let wrapper; + let fakeApollo; + + const getBaseToken = () => wrapper.findComponent(BaseToken); + + const searchGroupCrmOrganizationsQueryHandler = jest + .fn() + .mockResolvedValue(mockGroupCrmOrganizationsQueryResponse); + const searchProjectCrmOrganizationsQueryHandler = jest + .fn() + .mockResolvedValue(mockProjectCrmOrganizationsQueryResponse); + + const mountComponent = ({ + config = mockCrmOrganizationToken, + value = { data: '' }, + active = false, + stubs = defaultStubs, + listeners = {}, + queryHandler = searchGroupCrmOrganizationsQueryHandler, + } = {}) => { + fakeApollo = createMockApollo([[searchCrmOrganizationsQuery, queryHandler]]); + wrapper = mount(CrmOrganizationToken, { + propsData: { + config, + value, + active, + cursorPosition: 'start', + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: () => 'custom-class', + }, + stubs, + listeners, + apolloProvider: fakeApollo, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + describe('methods', () => { + describe('fetchOrganizations', () => { + describe('for groups', () => { + beforeEach(() => { + mountComponent(); + }); + + it('calls the apollo query providing the searchString when search term is a string', async () => { + getBaseToken().vm.$emit('fetch-suggestions', 'foo'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchGroupCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'group', + isProject: false, + searchString: 'foo', + searchIds: null, + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmOrganizations); + }); + + it('calls the apollo query providing the searchId when search term is a number', async () => { + getBaseToken().vm.$emit('fetch-suggestions', '5'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchGroupCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'group', + isProject: false, + searchString: null, + searchIds: ['gid://gitlab/CustomerRelations::Organization/5'], + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmOrganizations); + }); + }); + + describe('for projects', () => { + beforeEach(() => { + mountComponent({ + config: { + fullPath: 'project', + isProject: true, + }, + queryHandler: searchProjectCrmOrganizationsQueryHandler, + }); + }); + + it('calls the apollo query providing the searchString when search term is a string', async () => { + getBaseToken().vm.$emit('fetch-suggestions', 'foo'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchProjectCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'project', + isProject: true, + searchString: 'foo', + searchIds: null, + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmOrganizations); + }); + + it('calls the apollo query providing the searchId when search term is a number', async () => { + getBaseToken().vm.$emit('fetch-suggestions', '5'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchProjectCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'project', + isProject: true, + searchString: null, + searchIds: ['gid://gitlab/CustomerRelations::Organization/5'], + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmOrganizations); + }); + }); + + it('calls `createFlash` with flash error message when request fails', async () => { + mountComponent(); + + jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); + + getBaseToken().vm.$emit('fetch-suggestions'); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching CRM organizations.', + }); + }); + + it('sets `loading` to false when request completes', async () => { + mountComponent(); + + jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); + + getBaseToken().vm.$emit('fetch-suggestions'); + + await waitForPromises(); + + expect(getBaseToken().props('suggestionsLoading')).toBe(false); + }); + }); + }); + + describe('template', () => { + const defaultOrganizations = DEFAULT_NONE_ANY; + + it('renders base-token component', () => { + mountComponent({ + config: { ...mockCrmOrganizationToken, initialOrganizations: mockCrmOrganizations }, + value: { data: '1' }, + }); + + const baseTokenEl = wrapper.find(BaseToken); + + expect(baseTokenEl.exists()).toBe(true); + expect(baseTokenEl.props()).toMatchObject({ + suggestions: mockCrmOrganizations, + getActiveTokenValue: wrapper.vm.getActiveOrganization, + }); + }); + + it.each(mockCrmOrganizations)('renders token item when value is selected', (organization) => { + mountComponent({ + config: { ...mockCrmOrganizationToken, initialOrganizations: mockCrmOrganizations }, + value: { data: `${getIdFromGraphQLId(organization.id)}` }, + }); + + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + + expect(tokenSegments).toHaveLength(3); // Organization, =, Organization name + expect(tokenSegments.at(2).text()).toBe(organization.name); // Organization name + }); + + it('renders provided defaultOrganizations as suggestions', async () => { + mountComponent({ + active: true, + config: { ...mockCrmOrganizationToken, defaultOrganizations }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(defaultOrganizations.length); + defaultOrganizations.forEach((organization, index) => { + expect(suggestions.at(index).text()).toBe(organization.text); + }); + }); + + it('does not render divider when no defaultOrganizations', async () => { + mountComponent({ + active: true, + config: { ...mockCrmOrganizationToken, defaultOrganizations: [] }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await nextTick(); + + expect(wrapper.find(GlFilteredSearchSuggestion).exists()).toBe(false); + expect(wrapper.find(GlDropdownDivider).exists()).toBe(false); + }); + + it('renders `DEFAULT_NONE_ANY` as default suggestions', () => { + mountComponent({ + active: true, + config: { ...mockCrmOrganizationToken }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(DEFAULT_NONE_ANY.length); + DEFAULT_NONE_ANY.forEach((organization, index) => { + expect(suggestions.at(index).text()).toBe(organization.text); + }); + }); + + it('emits listeners in the base-token', () => { + const mockInput = jest.fn(); + mountComponent({ + listeners: { + input: mockInput, + }, + }); + wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]); + + expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index b3376f26a25..85a135d2b89 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -67,11 +67,6 @@ describe('Markdown field component', () => { enablePreview, restrictedToolBarItems, }, - provide: { - glFeatures: { - contactsAutocomplete: true, - }, - }, }, ); } diff --git a/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap b/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap index 5e956d66b6a..bf6c8e8c704 100644 --- a/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap +++ b/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap @@ -7,16 +7,19 @@ exports[`Issue placeholder note component matches snapshot 1`] = ` <div class="timeline-icon" > - <user-avatar-link-stub - imgalt="" - imgcssclasses="" - imgsize="40" - imgsrc="mock_path" - linkhref="/root" - tooltipplacement="top" - tooltiptext="" - username="" - /> + <gl-avatar-link-stub + class="gl-mr-3" + href="/root" + > + <gl-avatar-stub + alt="Root" + entityid="0" + entityname="root" + shape="circle" + size="[object Object]" + src="mock_path" + /> + </gl-avatar-link-stub> </div> <div diff --git a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js index 6881cb79740..f951cfd5cd9 100644 --- a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; +import { GlAvatar } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; import IssuePlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import { userDataMock } from 'jest/notes/mock_data'; Vue.use(Vuex); @@ -56,14 +56,14 @@ describe('Issue placeholder note component', () => { describe('avatar size', () => { it.each` - size | line | isOverviewTab - ${40} | ${null} | ${false} - ${24} | ${{ line_code: '123' }} | ${false} - ${40} | ${{ line_code: '123' }} | ${true} + size | line | isOverviewTab + ${{ default: 24, md: 32 }} | ${null} | ${false} + ${24} | ${{ line_code: '123' }} | ${false} + ${{ default: 24, md: 32 }} | ${{ line_code: '123' }} | ${true} `('renders avatar $size for $line and $isOverviewTab', ({ size, line, isOverviewTab }) => { createComponent(false, { line, isOverviewTab }); - expect(wrapper.findComponent(UserAvatarLink).props('imgSize')).toBe(size); + expect(wrapper.findComponent(GlAvatar).props('size')).toEqual(size); }); }); }); diff --git a/spec/frontend/vue_shared/components/page_size_selector_spec.js b/spec/frontend/vue_shared/components/page_size_selector_spec.js new file mode 100644 index 00000000000..5ec0b863afd --- /dev/null +++ b/spec/frontend/vue_shared/components/page_size_selector_spec.js @@ -0,0 +1,44 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import PageSizeSelector, { PAGE_SIZES } from '~/vue_shared/components/page_size_selector.vue'; + +describe('Page size selector component', () => { + let wrapper; + + const createWrapper = ({ pageSize = 20 } = {}) => { + wrapper = shallowMount(PageSizeSelector, { + propsData: { value: pageSize }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + + afterEach(() => { + wrapper.destroy(); + }); + + it.each(PAGE_SIZES)('shows expected text in the dropdown button for page size %s', (pageSize) => { + createWrapper({ pageSize }); + + expect(findDropdown().props('text')).toBe(`Show ${pageSize} items`); + }); + + it('shows the expected dropdown items', () => { + createWrapper(); + + PAGE_SIZES.forEach((pageSize, index) => { + expect(findDropdownItems().at(index).text()).toBe(`Show ${pageSize} items`); + }); + }); + + it('will emit the new page size when a dropdown item is clicked', () => { + createWrapper(); + + findDropdownItems().wrappers.forEach((itemWrapper, index) => { + itemWrapper.vm.$emit('click'); + + expect(wrapper.emitted('input')[index][0]).toBe(PAGE_SIZES[index]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js index 8270ff31574..51a936c0509 100644 --- a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js +++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js @@ -195,7 +195,7 @@ describe('AlertManagementEmptyState', () => { tabs.forEach((tab, i) => { const status = ITEMS_STATUS_TABS[i].status.toLowerCase(); expect(tab.attributes('data-testid')).toContain(ITEMS_STATUS_TABS[i].status); - expect(badges.at(i).text()).toContain(itemsCount[status]); + expect(badges.at(i).text()).toContain(itemsCount[status].toString()); }); }); }); diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap index 8ff49271eb5..2ea8985b16a 100644 --- a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap +++ b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap @@ -42,6 +42,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` > <gl-accordion-item-stub class="gl-font-weight-normal" + headerclass="" title="More Details" titlevisible="Less Details" > @@ -76,6 +77,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` > <gl-accordion-item-stub class="gl-font-weight-normal" + headerclass="" title="More Details" titlevisible="Less Details" > @@ -110,6 +112,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` > <gl-accordion-item-stub class="gl-font-weight-normal" + headerclass="" title="More Details" titlevisible="Less Details" > @@ -144,6 +147,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` > <gl-accordion-item-stub class="gl-font-weight-normal" + headerclass="" title="More Details" titlevisible="Less Details" > diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js index 7173abe1316..a38dcd626f4 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js +++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js @@ -79,7 +79,7 @@ describe('RunnerInstructionsModal component', () => { } }; - beforeEach(async () => { + beforeEach(() => { runnerPlatformsHandler = jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms); runnerSetupInstructionsHandler = jest.fn().mockResolvedValue(mockGraphqlInstructions); }); @@ -259,11 +259,11 @@ describe('RunnerInstructionsModal component', () => { }); describe('when apollo is loading', () => { - beforeEach(() => { + it('should show a skeleton loader', async () => { createComponent(); - }); + await nextTick(); + await nextTick(); - it('should show a skeleton loader', async () => { expect(findSkeletonLoader().exists()).toBe(true); expect(findGlLoadingIcon().exists()).toBe(false); @@ -275,6 +275,8 @@ describe('RunnerInstructionsModal component', () => { }); it('once loaded, should not show a loading state', async () => { + createComponent(); + await waitForPromises(); expect(findSkeletonLoader().exists()).toBe(false); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js new file mode 100644 index 00000000000..3036ce43888 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js @@ -0,0 +1,14 @@ +import packageJsonLinker from '~/vue_shared/components/source_viewer/plugins/utils/package_json_linker'; +import linkDependencies from '~/vue_shared/components/source_viewer/plugins/link_dependencies'; +import { PACKAGE_JSON_FILE_TYPE, PACKAGE_JSON_CONTENT } from './mock_data'; + +jest.mock('~/vue_shared/components/source_viewer/plugins/utils/package_json_linker'); + +describe('Highlight.js plugin for linking dependencies', () => { + const hljsResultMock = { value: 'test' }; + + it('calls packageJsonLinker for package_json file types', () => { + linkDependencies(hljsResultMock, PACKAGE_JSON_FILE_TYPE, PACKAGE_JSON_CONTENT); + expect(packageJsonLinker).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js b/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js new file mode 100644 index 00000000000..75659770e2c --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js @@ -0,0 +1,2 @@ +export const PACKAGE_JSON_FILE_TYPE = 'package_json'; +export const PACKAGE_JSON_CONTENT = '{ "dependencies": { "@babel/core": "^7.18.5" } }'; diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js new file mode 100644 index 00000000000..ee200747af9 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js @@ -0,0 +1,33 @@ +import { + createLink, + generateHLJSOpenTag, +} from '~/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util'; + +describe('createLink', () => { + it('generates a link with the correct attributes', () => { + const href = 'http://test.com'; + const innerText = 'testing'; + const result = `<a href="${href}" rel="nofollow noreferrer noopener">${innerText}</a>`; + + expect(createLink(href, innerText)).toBe(result); + }); + + it('escapes the user-controlled content', () => { + const unescapedXSS = '<script>XSS</script>'; + const escapedXSS = '&lt;script&gt;XSS&lt;/script&gt;'; + const href = `http://test.com/${unescapedXSS}`; + const innerText = `testing${unescapedXSS}`; + const result = `<a href="http://test.com/${escapedXSS}" rel="nofollow noreferrer noopener">testing${escapedXSS}</a>`; + + expect(createLink(href, innerText)).toBe(result); + }); +}); + +describe('generateHLJSOpenTag', () => { + it('generates an open tag with the correct selector', () => { + const type = 'string'; + const result = `<span class="hljs-${type}">"`; + + expect(generateHLJSOpenTag(type)).toBe(result); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/package_json_linker_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/package_json_linker_spec.js new file mode 100644 index 00000000000..e83c129818c --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/package_json_linker_spec.js @@ -0,0 +1,15 @@ +import packageJsonLinker from '~/vue_shared/components/source_viewer/plugins/utils/package_json_linker'; +import { PACKAGE_JSON_CONTENT } from '../mock_data'; + +describe('Highlight.js plugin for linking package.json dependencies', () => { + it('mutates the input value by wrapping dependency names and versions in anchors', () => { + const inputValue = + '<span class="hljs-attr">"@babel/core"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"^7.18.5"</span>'; + const outputValue = + '<span class="hljs-attr">"<a href="https://npmjs.com/package/@babel/core" rel="nofollow noreferrer noopener">@babel/core</a>"</span>: <span class="hljs-attr">"<a href="https://npmjs.com/package/@babel/core" rel="nofollow noreferrer noopener">^7.18.5</a>"</span>'; + const hljsResultMock = { value: inputValue }; + + const output = packageJsonLinker(hljsResultMock, PACKAGE_JSON_CONTENT); + expect(output).toBe(outputValue); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js index bb0945a1f3e..2c03b7aa7d3 100644 --- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js @@ -5,10 +5,16 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue'; import { registerPlugins } from '~/vue_shared/components/source_viewer/plugins/index'; import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue'; -import { ROUGE_TO_HLJS_LANGUAGE_MAP } from '~/vue_shared/components/source_viewer/constants'; +import { + EVENT_ACTION, + EVENT_LABEL_VIEWER, + EVENT_LABEL_FALLBACK, + ROUGE_TO_HLJS_LANGUAGE_MAP, +} from '~/vue_shared/components/source_viewer/constants'; import waitForPromises from 'helpers/wait_for_promises'; import LineHighlighter from '~/blob/line_highlighter'; import eventHub from '~/notes/event_hub'; +import Tracking from '~/tracking'; jest.mock('~/blob/line_highlighter'); jest.mock('highlight.js/lib/core'); @@ -34,7 +40,8 @@ describe('Source Viewer component', () => { const chunk2 = generateContent('// Some source code 2', 70); const content = chunk1 + chunk2; const path = 'some/path.js'; - const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path }; + const fileType = 'javascript'; + const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path, fileType }; const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`; const createComponent = async (blob = {}) => { @@ -52,17 +59,38 @@ describe('Source Viewer component', () => { hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent })); jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); jest.spyOn(eventHub, '$emit'); + jest.spyOn(Tracking, 'event'); return createComponent(); }); afterEach(() => wrapper.destroy()); + describe('event tracking', () => { + it('fires a tracking event when the component is created', () => { + const eventData = { label: EVENT_LABEL_VIEWER, property: language }; + expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); + }); + + it('does not emit an error event when the language is supported', () => { + expect(wrapper.emitted('error')).toBeUndefined(); + }); + + it('fires a tracking event and emits an error when the language is not supported', () => { + const unsupportedLanguage = 'apex'; + const eventData = { label: EVENT_LABEL_FALLBACK, property: unsupportedLanguage }; + createComponent({ language: unsupportedLanguage }); + + expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); + expect(wrapper.emitted('error')).toHaveLength(1); + }); + }); + describe('highlight.js', () => { beforeEach(() => createComponent({ language: mappedLanguage })); it('registers our plugins for Highlight.js', () => { - expect(registerPlugins).toHaveBeenCalledWith(hljs); + expect(registerPlugins).toHaveBeenCalledWith(hljs, fileType, content); }); it('registers the language definition', async () => { @@ -74,6 +102,13 @@ describe('Source Viewer component', () => { ); }); + it('registers json language definition if fileType is package_json', async () => { + await createComponent({ language: 'json', fileType: 'package_json' }); + const languageDefinition = await import(`highlight.js/lib/languages/json`); + + expect(hljs.registerLanguage).toHaveBeenCalledWith('json', languageDefinition.default); + }); + it('highlights the first chunk', () => { expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage }); }); diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js index a54f3450633..9550368eefc 100644 --- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js @@ -2,7 +2,6 @@ import { GlSkeletonLoader, GlIcon } from '@gitlab/ui'; import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { AVAILABILITY_STATUS } from '~/set_status_modal/utils'; -import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; import UserPopover from '~/vue_shared/components/user_popover/user_popover.vue'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; @@ -48,7 +47,6 @@ describe('User Popover Component', () => { const findUserStatus = () => wrapper.findByTestId('user-popover-status'); const findTarget = () => document.querySelector('.js-user-link'); - const findUserName = () => wrapper.find(UserNameWithStatus); const findSecurityBotDocsLink = () => wrapper.findByTestId('user-popover-bot-docs-link'); const findUserLocalTime = () => wrapper.findByTestId('user-popover-local-time'); const findToggleFollowButton = () => wrapper.findByTestId('toggle-follow-button'); @@ -245,9 +243,7 @@ describe('User Popover Component', () => { createWrapper({ user }); - expect(findUserName().exists()).toBe(true); - expect(wrapper.text()).toContain(user.name); - expect(wrapper.text()).toContain('(Busy)'); + expect(wrapper.findByText('(Busy)').exists()).toBe(true); }); it('should hide the busy status for any other status', () => { @@ -258,13 +254,32 @@ describe('User Popover Component', () => { createWrapper({ user }); - expect(wrapper.text()).not.toContain('(Busy)'); + expect(wrapper.findByText('(Busy)').exists()).toBe(false); }); - it('passes `pronouns` prop to `UserNameWithStatus` component', () => { + it('shows pronouns when user has them set', () => { createWrapper(); - expect(findUserName().props('pronouns')).toBe('they/them'); + expect(wrapper.findByText('(they/them)').exists()).toBe(true); + }); + + describe.each` + pronouns + ${undefined} + ${null} + ${''} + ${' '} + `('when pronouns are set to $pronouns', ({ pronouns }) => { + it('does not render pronouns', () => { + const user = { + ...DEFAULT_PROPS.user, + pronouns, + }; + + createWrapper({ user }); + + expect(wrapper.findByTestId('user-popover-pronouns').exists()).toBe(false); + }); }); }); |