diff options
Diffstat (limited to 'spec/frontend/vue_shared')
42 files changed, 1508 insertions, 578 deletions
diff --git a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap index 2b89e36344d..62d75fbdc5f 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap @@ -12,6 +12,7 @@ exports[`SplitButton renders actionItems 1`] = ` menu-class="" size="medium" split="true" + splithref="" text="professor" variant="default" > diff --git a/spec/frontend/vue_shared/components/actions_button_spec.js b/spec/frontend/vue_shared/components/actions_button_spec.js deleted file mode 100644 index 9f9a27c6997..00000000000 --- a/spec/frontend/vue_shared/components/actions_button_spec.js +++ /dev/null @@ -1,119 +0,0 @@ -import { - GlDisclosureDropdown, - GlDisclosureDropdownGroup, - GlDisclosureDropdownItem, -} from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import ActionsButton from '~/vue_shared/components/actions_button.vue'; - -const TEST_ACTION = { - key: 'action1', - text: 'Sample', - secondaryText: 'Lorem ipsum.', - href: '/sample', - attrs: { - 'data-test': '123', - category: 'secondary', - href: '/sample', - variant: 'default', - }, - handle: jest.fn(), -}; -const TEST_ACTION_2 = { - key: 'action2', - text: 'Sample 2', - secondaryText: 'Dolar sit amit.', - href: '#', - attrs: { 'data-test': '456' }, - handle: jest.fn(), -}; - -describe('vue_shared/components/actions_button', () => { - let wrapper; - - function createComponent({ props = {}, slots = {} } = {}) { - wrapper = shallowMountExtended(ActionsButton, { - propsData: { actions: [TEST_ACTION, TEST_ACTION_2], toggleText: 'Edit', ...props }, - stubs: { - GlDisclosureDropdownItem, - }, - slots, - }); - } - const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); - - it('dropdown toggle displays provided toggleLabel', () => { - createComponent(); - - expect(findDropdown().props().toggleText).toBe('Edit'); - }); - - it('dropdown has a fluid width', () => { - createComponent(); - - expect(findDropdown().props().fluidWidth).toBe(true); - }); - - it('provides a default slot', () => { - const slotContent = 'default text'; - - createComponent({ - slots: { - default: slotContent, - }, - }); - - expect(findDropdown().text()).toContain(slotContent); - }); - - it('allows customizing variant and category', () => { - const variant = 'confirm'; - const category = 'secondary'; - - createComponent({ props: { variant, category } }); - - expect(findDropdown().props()).toMatchObject({ category, variant }); - }); - - it('displays a single dropdown group', () => { - createComponent(); - - expect(wrapper.findAllComponents(GlDisclosureDropdownGroup)).toHaveLength(1); - }); - - it('create dropdown items for every action', () => { - createComponent(); - - [TEST_ACTION, TEST_ACTION_2].forEach((action, index) => { - const dropdownItem = wrapper.findAllComponents(GlDisclosureDropdownItem).at(index); - - expect(dropdownItem.props().item).toBe(action); - expect(dropdownItem.attributes()).toMatchObject(action.attrs); - expect(dropdownItem.text()).toContain(action.text); - expect(dropdownItem.text()).toContain(action.secondaryText); - }); - }); - - describe('when clicking a dropdown item', () => { - it("invokes the action's handle method", () => { - createComponent(); - - [TEST_ACTION, TEST_ACTION_2].forEach((action, index) => { - const dropdownItem = wrapper.findAllComponents(GlDisclosureDropdownItem).at(index); - - dropdownItem.vm.$emit('action'); - - expect(action.handle).toHaveBeenCalled(); - }); - }); - }); - - it.each(['shown', 'hidden'])( - 'bubbles up %s event from the disclosure dropdown component', - (event) => { - createComponent(); - findDropdown().vm.$emit(event); - expect(wrapper.emitted(event)).toHaveLength(1); - }, - ); -}); diff --git a/spec/frontend/vue_shared/components/badges/__snapshots__/beta_badge_spec.js.snap b/spec/frontend/vue_shared/components/badges/__snapshots__/beta_badge_spec.js.snap new file mode 100644 index 00000000000..24b2c54f20b --- /dev/null +++ b/spec/frontend/vue_shared/components/badges/__snapshots__/beta_badge_spec.js.snap @@ -0,0 +1,54 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Beta badge component renders the badge 1`] = ` +<div> + <gl-badge-stub + class="gl-cursor-pointer" + href="#" + iconsize="md" + size="md" + variant="neutral" + > + Beta + </gl-badge-stub> + + <gl-popover-stub + cssclasses="" + data-testid="beta-badge" + showclosebutton="true" + target="[Function]" + title="What's Beta?" + triggers="hover focus click" + > + <p> + A Beta feature is not production-ready, but is unlikely to change drastically before it's released. We encourage users to try Beta features and provide feedback. + </p> + + <p + class="gl-mb-0" + > + A Beta feature: + </p> + + <ul + class="gl-pl-4" + > + <li> + May be unstable. + </li> + + <li> + Should not cause data loss. + </li> + + <li> + Is supported by a commercially reasonable effort. + </li> + + <li> + Is complete or near completion. + </li> + </ul> + </gl-popover-stub> +</div> +`; diff --git a/spec/frontend/vue_shared/components/badges/beta_badge_spec.js b/spec/frontend/vue_shared/components/badges/beta_badge_spec.js new file mode 100644 index 00000000000..c930c6d5708 --- /dev/null +++ b/spec/frontend/vue_shared/components/badges/beta_badge_spec.js @@ -0,0 +1,32 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlBadge } from '@gitlab/ui'; +import BetaBadge from '~/vue_shared/components/badges/beta_badge.vue'; + +describe('Beta badge component', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + const createWrapper = (props = {}) => { + wrapper = shallowMount(BetaBadge, { + propsData: { ...props }, + }); + }; + + it('renders the badge', () => { + createWrapper(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('passes default size to badge', () => { + createWrapper(); + + expect(findBadge().props('size')).toBe('md'); + }); + + it('passes given size to badge', () => { + createWrapper({ size: 'sm' }); + + expect(findBadge().props('size')).toBe('sm'); + }); +}); diff --git a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js index 1f3029435ee..fc8155bd381 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js +++ b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js @@ -3,8 +3,10 @@ import { shallowMount } from '@vue/test-utils'; import { handleBlobRichViewer } from '~/blob/viewer'; import RichViewer from '~/vue_shared/components/blob_viewers/rich_viewer.vue'; import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue'; +import { handleLocationHash } from '~/lib/utils/common_utils'; jest.mock('~/blob/viewer'); +jest.mock('~/lib/utils/common_utils'); describe('Blob Rich Viewer component', () => { let wrapper; @@ -50,4 +52,8 @@ describe('Blob Rich Viewer component', () => { it('is using Markdown View Field', () => { expect(wrapper.findComponent(MarkdownFieldView).exists()).toBe(true); }); + + it('scrolls to the hash location', () => { + expect(handleLocationHash).toHaveBeenCalled(); + }); }); diff --git a/spec/frontend/vue_shared/components/clipboard_button_spec.js b/spec/frontend/vue_shared/components/clipboard_button_spec.js index 08a9c2a42d8..271c99be57a 100644 --- a/spec/frontend/vue_shared/components/clipboard_button_spec.js +++ b/spec/frontend/vue_shared/components/clipboard_button_spec.js @@ -1,7 +1,8 @@ import { GlButton } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { mount, createWrapper as makeWrapper } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { BV_HIDE_TOOLTIP, BV_SHOW_TOOLTIP } from '~/lib/utils/constants'; import initCopyToClipboard, { CLIPBOARD_SUCCESS_EVENT, CLIPBOARD_ERROR_EVENT, @@ -31,7 +32,7 @@ describe('clipboard button', () => { title, }); - wrapper.vm.$root.$emit = jest.fn(); + const rootWrapper = makeWrapper(wrapper.vm.$root); const button = findButton(); @@ -42,7 +43,7 @@ describe('clipboard button', () => { await button.trigger(event); - expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith('bv::show::tooltip', 'clipboard-button-1'); + expect(rootWrapper.emitted(BV_SHOW_TOOLTIP)[0]).toContain('clipboard-button-1'); expect(button.attributes()).toMatchObject({ title: message, @@ -56,7 +57,7 @@ describe('clipboard button', () => { title, 'aria-label': title, }); - expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith('bv::hide::tooltip', 'clipboard-button-1'); + expect(rootWrapper.emitted(BV_HIDE_TOOLTIP)[0]).toContain('clipboard-button-1'); }; describe('without gfm', () => { 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 index 0d536b23c45..2f165338577 100644 --- a/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js +++ b/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js @@ -1,5 +1,6 @@ import { shallowMount, mount } from '@vue/test-utils'; import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import { GlAlert, GlLink, GlLoadingIcon } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; 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 index 21a1303ccf3..ce8897027a4 100644 --- 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 @@ -110,6 +110,8 @@ describe('processFilters', () => { { type: 'foo', value: { data: 'foo', operator: '=' } }, { type: 'bar', value: { data: 'bar1', operator: '=' } }, { type: 'bar', value: { data: 'bar2', operator: '!=' } }, + 'just a string', + 'and another', ]); expect(result).toStrictEqual({ @@ -118,6 +120,10 @@ describe('processFilters', () => { { value: 'bar1', operator: '=' }, { value: 'bar2', operator: '!=' }, ], + 'filtered-search-term': [ + { value: 'just a string', operator: undefined }, + { value: 'and another', operator: undefined }, + ], }); }); @@ -208,6 +214,67 @@ describe('filterToQueryObject', () => { expect(res).toEqual(result); }, ); + + describe('with custom operators', () => { + it('does not handle filters without custom operators', () => { + const res = filterToQueryObject({ + foo: [ + { value: '100', operator: '>' }, + { value: '200', operator: '<' }, + ], + }); + expect(res).toEqual({ foo: null, 'not[foo]': null }); + }); + + it('handles filters with custom operators', () => { + const res = filterToQueryObject( + { + foo: [ + { value: '100', operator: '>' }, + { value: '200', operator: '<' }, + ], + }, + { + customOperators: [ + { + operator: '>', + prefix: 'gt', + }, + { + operator: '<', + prefix: 'lt', + }, + ], + }, + ); + expect(res).toEqual({ foo: null, 'gt[foo]': ['100'], 'lt[foo]': ['200'], 'not[foo]': null }); + }); + }); + + it('when applyOnlyToKey is present, it only process custom operators for the given key', () => { + const res = filterToQueryObject( + { + foo: [{ value: '100', operator: '>' }], + bar: [{ value: '100', operator: '>' }], + }, + { + customOperators: [ + { + operator: '>', + prefix: 'gt', + applyOnlyToKey: 'foo', + }, + ], + }, + ); + expect(res).toEqual({ + bar: null, + 'not[bar]': null, + foo: null, + 'gt[foo]': ['100'], + 'not[foo]': null, + }); + }); }); describe('urlQueryToFilter', () => { @@ -275,28 +342,40 @@ describe('urlQueryToFilter', () => { [ 'search=my terms', { - [FILTERED_SEARCH_TERM]: [{ value: 'my' }, { value: 'terms' }], + [FILTERED_SEARCH_TERM]: [{ value: 'my terms' }], }, { filteredSearchTermKey: 'search' }, ], [ 'search[]=my&search[]=terms', { - [FILTERED_SEARCH_TERM]: [{ value: 'my' }, { value: 'terms' }], + [FILTERED_SEARCH_TERM]: [{ value: 'my terms' }], }, { filteredSearchTermKey: 'search' }, ], [ 'search=my+terms', { - [FILTERED_SEARCH_TERM]: [{ value: 'my' }, { value: 'terms' }], + [FILTERED_SEARCH_TERM]: [{ value: 'my terms' }], }, { filteredSearchTermKey: 'search' }, ], [ 'search=my terms&foo=bar&nop=xxx', { - [FILTERED_SEARCH_TERM]: [{ value: 'my' }, { value: 'terms' }], + [FILTERED_SEARCH_TERM]: [{ value: 'my terms' }], + foo: { value: 'bar', operator: '=' }, + }, + { filteredSearchTermKey: 'search', filterNamesAllowList: ['foo'] }, + ], + [ + { + search: 'my terms', + foo: 'bar', + nop: 'xxx', + }, + { + [FILTERED_SEARCH_TERM]: [{ value: 'my terms' }], foo: { value: 'bar', operator: '=' }, }, { filteredSearchTermKey: 'search', filterNamesAllowList: ['foo'] }, @@ -308,6 +387,20 @@ describe('urlQueryToFilter', () => { expect(res).toEqual(result); }, ); + + describe('custom operators', () => { + it('handles query param with custom operators', () => { + const res = urlQueryToFilter('gt[foo]=bar', { + customOperators: [{ operator: '>', prefix: 'gt' }], + }); + expect(res).toEqual({ foo: { operator: '>', value: 'bar' } }); + }); + + it('does not handle query param without custom operators', () => { + const res = urlQueryToFilter('gt[foo]=bar'); + expect(res).toEqual({ 'gt[foo]': { operator: '=', value: 'bar' } }); + }); + }); }); describe('getRecentlyUsedSuggestions', () => { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/date_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/date_token_spec.js new file mode 100644 index 00000000000..56a59790210 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/date_token_spec.js @@ -0,0 +1,49 @@ +import { GlDatepicker, GlFilteredSearchToken } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import DateToken from '~/vue_shared/components/filtered_search_bar/tokens/date_token.vue'; + +const propsData = { + active: true, + config: {}, + value: { operator: '>', data: null }, +}; + +function createComponent() { + return mount(DateToken, { + propsData, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + termsAsTokens: () => false, + }, + }); +} + +describe('DateToken', () => { + let wrapper; + + const findGlFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findDatepicker = () => wrapper.findComponent(GlDatepicker); + + beforeEach(() => { + wrapper = createComponent(); + }); + + it('renders GlDatepicker', () => { + expect(findDatepicker().exists()).toBe(true); + }); + + it('renders GlFilteredSearchToken', () => { + expect(findGlFilteredSearchToken().exists()).toBe(true); + }); + + it('emits `complete` and `select` with the formatted date when a value is selected', () => { + findDatepicker().vm.$emit('input', new Date('October 13, 2014 11:13:00')); + findDatepicker().vm.$emit('close'); + + expect(findGlFilteredSearchToken().emitted()).toEqual({ + complete: [[]], + select: [['2014-10-13']], + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js index 5e675c10038..db116a31de7 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js @@ -122,7 +122,7 @@ describe('EmojiToken', () => { it('calls `createAlert`', () => { expect(createAlert).toHaveBeenCalledWith({ - message: 'There was a problem fetching emojis.', + message: 'There was a problem fetching emoji.', }); }); diff --git a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js index 4f1603f93ba..eee85ce4fd3 100644 --- a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js +++ b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js @@ -1,26 +1,25 @@ -import { merge } from 'lodash'; +import { nextTick } from 'vue'; import { GlFormInputGroup } from '@gitlab/ui'; import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; - import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { MOUSETRAP_COPY_KEYBOARD_SHORTCUT } from '~/lib/mousetrap'; describe('InputCopyToggleVisibility', () => { let wrapper; const valueProp = 'hR8x1fuJbzwu5uFKLf9e'; - const createComponent = (options = {}) => { - wrapper = mountExtended( - InputCopyToggleVisibility, - merge({}, options, { - directives: { - GlTooltip: createMockDirective('gl-tooltip'), - }, - }), - ); + const createComponent = ({ props, ...options } = {}) => { + wrapper = mountExtended(InputCopyToggleVisibility, { + propsData: props, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + ...options, + }); }; const findFormInputGroup = () => wrapper.findComponent(GlFormInputGroup); @@ -40,6 +39,18 @@ describe('InputCopyToggleVisibility', () => { return event; }; + const triggerCopyShortcut = () => { + wrapper.vm.$options.mousetrap.trigger(MOUSETRAP_COPY_KEYBOARD_SHORTCUT); + }; + + function expectInputToBeMasked() { + expect(findFormInput().element.type).toBe('password'); + } + + function expectInputToBeRevealed() { + expect(findFormInput().element.type).toBe('text'); + expect(findFormInput().element.value).toBe(valueProp); + } const itDoesNotModifyCopyEvent = () => { it('does not modify copy event', () => { @@ -55,35 +66,61 @@ describe('InputCopyToggleVisibility', () => { describe('when `value` prop is passed', () => { beforeEach(() => { createComponent({ - propsData: { + props: { value: valueProp, }, }); }); - it('displays value as hidden', () => { - expect(findFormInput().element.value).toBe('********************'); + it('hides the value with a password input', () => { + expectInputToBeMasked(); }); - it('saves actual value to clipboard when manually copied', () => { - const event = createCopyEvent(); - findFormInput().element.dispatchEvent(event); - - expect(event.clipboardData.setData).toHaveBeenCalledWith('text/plain', valueProp); - expect(event.preventDefault).toHaveBeenCalled(); - }); + it('emits `copy` event and sets clipboard when copying token via keyboard shortcut', async () => { + const writeTextSpy = jest.spyOn(global.navigator.clipboard, 'writeText'); - it('emits `copy` event when manually copied the token', () => { expect(wrapper.emitted('copy')).toBeUndefined(); - findFormInput().element.dispatchEvent(createCopyEvent()); + triggerCopyShortcut(); + await nextTick(); - expect(wrapper.emitted()).toHaveProperty('copy'); - expect(wrapper.emitted('copy')).toHaveLength(1); expect(wrapper.emitted('copy')[0]).toEqual([]); + expect(writeTextSpy).toHaveBeenCalledWith(valueProp); }); + describe('copy button', () => { + it('renders button with correct props passed', () => { + expect(findCopyButton().props()).toMatchObject({ + text: valueProp, + title: 'Copy', + }); + }); + + describe('when clicked', () => { + beforeEach(async () => { + await findCopyButton().trigger('click'); + }); + + it('emits `copy` event', () => { + expect(wrapper.emitted()).toHaveProperty('copy'); + expect(wrapper.emitted('copy')).toHaveLength(1); + expect(wrapper.emitted('copy')[0]).toEqual([]); + }); + }); + }); + }); + + describe('when input is readonly', () => { describe('visibility toggle button', () => { + beforeEach(() => { + createComponent({ + props: { + value: valueProp, + readonly: true, + }, + }); + }); + it('renders a reveal button', () => { const revealButton = findRevealButton(); @@ -103,7 +140,7 @@ describe('InputCopyToggleVisibility', () => { }); it('displays value', () => { - expect(findFormInput().element.value).toBe(valueProp); + expectInputToBeRevealed(); }); it('renders a hide button', () => { @@ -127,78 +164,161 @@ describe('InputCopyToggleVisibility', () => { }); }); - describe('copy button', () => { - it('renders button with correct props passed', () => { - expect(findCopyButton().props()).toMatchObject({ - text: valueProp, - title: 'Copy', + describe('when `initialVisibility` prop is `true`', () => { + const label = 'My label'; + beforeEach(() => { + createComponent({ + props: { + value: valueProp, + initialVisibility: true, + readonly: true, + label, + 'label-for': 'my-input', + formInputGroupProps: { + id: 'my-input', + }, + }, }); }); - describe('when clicked', () => { - beforeEach(async () => { - await findCopyButton().trigger('click'); + it('displays value', () => { + expectInputToBeRevealed(); + }); + + itDoesNotModifyCopyEvent(); + + describe('when input is clicked', () => { + it('selects input value', async () => { + const mockSelect = jest.fn(); + findFormInput().element.select = mockSelect; + await findFormInput().trigger('click'); + + expect(mockSelect).toHaveBeenCalled(); }); + }); - it('emits `copy` event', () => { - expect(wrapper.emitted()).toHaveProperty('copy'); - expect(wrapper.emitted('copy')).toHaveLength(1); - expect(wrapper.emitted('copy')[0]).toEqual([]); + describe('when label is clicked', () => { + it('selects input value', async () => { + const mockSelect = jest.fn(); + findFormInput().element.select = mockSelect; + await wrapper.find('label').trigger('click'); + + expect(mockSelect).toHaveBeenCalled(); }); }); }); }); - describe('when `value` prop is not passed', () => { - beforeEach(() => { - createComponent(); - }); + describe('when input is editable', () => { + describe('and no `value` prop is passed', () => { + beforeEach(() => { + createComponent({ + props: { + value: '', + readonly: false, + }, + }); + }); - it('displays value as hidden with 20 asterisks', () => { - expect(findFormInput().element.value).toBe('********************'); - }); - }); + it('displays value', () => { + expect(findRevealButton().exists()).toBe(false); + expect(findHideButton().exists()).toBe(true); - describe('when `initialVisibility` prop is `true`', () => { - const label = 'My label'; + const input = findFormInput(); + input.element.value = valueProp; + input.trigger('input'); - beforeEach(() => { - createComponent({ - propsData: { - value: valueProp, - initialVisibility: true, - label, - 'label-for': 'my-input', - formInputGroupProps: { - id: 'my-input', - }, - }, + expectInputToBeRevealed(); }); }); - it('displays value', () => { - expect(findFormInput().element.value).toBe(valueProp); - }); + describe('and `value` prop is passed', () => { + beforeEach(() => { + createComponent({ + props: { + value: valueProp, + readonly: false, + }, + }); + }); - itDoesNotModifyCopyEvent(); + it('renders a reveal button', () => { + const revealButton = findRevealButton(); + + expect(revealButton.exists()).toBe(true); - describe('when input is clicked', () => { - it('selects input value', async () => { - const mockSelect = jest.fn(); - wrapper.vm.$refs.input.$el.select = mockSelect; - await wrapper.findByLabelText(label).trigger('click'); + const tooltip = getBinding(revealButton.element, 'gl-tooltip'); - expect(mockSelect).toHaveBeenCalled(); + expect(tooltip.value).toBe(InputCopyToggleVisibility.i18n.toggleVisibilityLabelReveal); }); - }); - describe('when label is clicked', () => { - it('selects input value', async () => { - const mockSelect = jest.fn(); - wrapper.vm.$refs.input.$el.select = mockSelect; - await wrapper.find('label').trigger('click'); + it('renders a hide button once revealed', async () => { + const revealButton = findRevealButton(); + await revealButton.trigger('click'); + await nextTick(); + + const hideButton = findHideButton(); + expect(hideButton.exists()).toBe(true); + + const tooltip = getBinding(hideButton.element, 'gl-tooltip'); - expect(mockSelect).toHaveBeenCalled(); + expect(tooltip.value).toBe(InputCopyToggleVisibility.i18n.toggleVisibilityLabelHide); + }); + + it('emits `input` event when editing', () => { + expect(wrapper.emitted('input')).toBeUndefined(); + const newVal = 'ding!'; + + const input = findFormInput(); + input.element.value = newVal; + input.trigger('input'); + + expect(wrapper.emitted()).toHaveProperty('input'); + expect(wrapper.emitted('input')).toHaveLength(1); + expect(wrapper.emitted('input')[0][0]).toBe(newVal); + }); + + it('copies updated value to clipboard after editing', async () => { + const writeTextSpy = jest.spyOn(global.navigator.clipboard, 'writeText'); + + triggerCopyShortcut(); + await nextTick(); + + expect(wrapper.emitted('copy')).toHaveLength(1); + expect(writeTextSpy).toHaveBeenCalledWith(valueProp); + + const updatedValue = 'wow amazing'; + wrapper.setProps({ value: updatedValue }); + await nextTick(); + + triggerCopyShortcut(); + await nextTick(); + + expect(wrapper.emitted('copy')).toHaveLength(2); + expect(writeTextSpy).toHaveBeenCalledWith(updatedValue); + }); + + describe('when input is clicked', () => { + it('shows the actual value', async () => { + const input = findFormInput(); + + expectInputToBeMasked(); + await findFormInput().trigger('click'); + + expect(input.element.value).toBe(valueProp); + }); + + it('ensures the selection start/end are in the correct position once the actual value has been revealed', async () => { + const input = findFormInput(); + const selectionStart = 2; + const selectionEnd = 4; + + input.element.setSelectionRange(selectionStart, selectionEnd); + await input.trigger('click'); + + expect(input.element.selectionStart).toBe(selectionStart); + expect(input.element.selectionEnd).toBe(selectionEnd); + }); }); }); }); @@ -206,7 +326,7 @@ describe('InputCopyToggleVisibility', () => { describe('when `showToggleVisibilityButton` is `false`', () => { beforeEach(() => { createComponent({ - propsData: { + props: { value: valueProp, showToggleVisibilityButton: false, }, @@ -219,7 +339,7 @@ describe('InputCopyToggleVisibility', () => { }); it('displays value', () => { - expect(findFormInput().element.value).toBe(valueProp); + expectInputToBeRevealed(); }); itDoesNotModifyCopyEvent(); @@ -228,7 +348,7 @@ describe('InputCopyToggleVisibility', () => { describe('when `showCopyButton` is `false`', () => { beforeEach(() => { createComponent({ - propsData: { + props: { showCopyButton: false, }, }); @@ -239,9 +359,23 @@ describe('InputCopyToggleVisibility', () => { }); }); + describe('when `size` is used', () => { + it('passes no `size` prop', () => { + createComponent(); + + expect(findFormInput().props('size')).toBe(null); + }); + + it('passes `size` prop to the input', () => { + createComponent({ props: { size: 'md' } }); + + expect(findFormInput().props('size')).toBe('md'); + }); + }); + it('passes `formInputGroupProps` prop only to the input', () => { createComponent({ - propsData: { + props: { formInputGroupProps: { name: 'Foo bar', 'data-qa-selector': 'Foo bar', @@ -267,7 +401,7 @@ describe('InputCopyToggleVisibility', () => { it('passes `copyButtonTitle` prop to `ClipboardButton`', () => { createComponent({ - propsData: { + props: { copyButtonTitle: 'Copy token', }, }); diff --git a/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js b/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js index 6dc018797a6..271214907fc 100644 --- a/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js +++ b/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js @@ -1,6 +1,7 @@ import { GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue'; diff --git a/spec/frontend/vue_shared/components/groups_list/groups_list_item_spec.js b/spec/frontend/vue_shared/components/groups_list/groups_list_item_spec.js new file mode 100644 index 00000000000..877de4f4695 --- /dev/null +++ b/spec/frontend/vue_shared/components/groups_list/groups_list_item_spec.js @@ -0,0 +1,182 @@ +import { GlAvatarLabeled, GlIcon } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import GroupsListItem from '~/vue_shared/components/groups_list/groups_list_item.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { + VISIBILITY_TYPE_ICON, + VISIBILITY_LEVEL_INTERNAL_STRING, + GROUP_VISIBILITY_TYPE, +} from '~/visibility_level/constants'; +import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; +import { ACCESS_LEVEL_LABELS } from '~/access_level/constants'; +import { groups } from './mock_data'; + +describe('GroupsListItem', () => { + let wrapper; + + const [group] = groups; + + const defaultPropsData = { group }; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = mountExtended(GroupsListItem, { + propsData: { ...defaultPropsData, ...propsData }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + }); + }; + + const findAvatarLabeled = () => wrapper.findComponent(GlAvatarLabeled); + const findGroupDescription = () => wrapper.findByTestId('group-description'); + const findVisibilityIcon = () => findAvatarLabeled().findComponent(GlIcon); + + it('renders group avatar', () => { + createComponent(); + + const avatarLabeled = findAvatarLabeled(); + + expect(avatarLabeled.props()).toMatchObject({ + label: group.fullName, + labelLink: group.webUrl, + }); + + expect(avatarLabeled.attributes()).toMatchObject({ + 'entity-id': group.id.toString(), + 'entity-name': group.fullName, + shape: 'rect', + }); + }); + + it('renders visibility icon with tooltip', () => { + createComponent(); + + const icon = findAvatarLabeled().findComponent(GlIcon); + const tooltip = getBinding(icon.element, 'gl-tooltip'); + + expect(icon.props('name')).toBe(VISIBILITY_TYPE_ICON[VISIBILITY_LEVEL_INTERNAL_STRING]); + expect(tooltip.value).toBe(GROUP_VISIBILITY_TYPE[VISIBILITY_LEVEL_INTERNAL_STRING]); + }); + + it('renders subgroup count', () => { + createComponent(); + + const countWrapper = wrapper.findByTestId('subgroups-count'); + const tooltip = getBinding(countWrapper.element, 'gl-tooltip'); + + expect(tooltip.value).toBe(GroupsListItem.i18n.subgroups); + expect(countWrapper.text()).toBe(group.descendantGroupsCount.toString()); + expect(countWrapper.findComponent(GlIcon).props('name')).toBe('subgroup'); + }); + + it('renders projects count', () => { + createComponent(); + + const countWrapper = wrapper.findByTestId('projects-count'); + const tooltip = getBinding(countWrapper.element, 'gl-tooltip'); + + expect(tooltip.value).toBe(GroupsListItem.i18n.projects); + expect(countWrapper.text()).toBe(group.projectsCount.toString()); + expect(countWrapper.findComponent(GlIcon).props('name')).toBe('project'); + }); + + it('renders members count', () => { + createComponent(); + + const countWrapper = wrapper.findByTestId('members-count'); + const tooltip = getBinding(countWrapper.element, 'gl-tooltip'); + + expect(tooltip.value).toBe(GroupsListItem.i18n.directMembers); + expect(countWrapper.text()).toBe(group.groupMembersCount.toString()); + expect(countWrapper.findComponent(GlIcon).props('name')).toBe('users'); + }); + + describe('when visibility is not provided', () => { + it('does not render visibility icon', () => { + const { visibility, ...groupWithoutVisibility } = group; + createComponent({ + propsData: { + group: groupWithoutVisibility, + }, + }); + + expect(findVisibilityIcon().exists()).toBe(false); + }); + }); + + it('renders access role badge', () => { + createComponent(); + + expect(findAvatarLabeled().findComponent(UserAccessRoleBadge).text()).toBe( + ACCESS_LEVEL_LABELS[group.accessLevel.integerValue], + ); + }); + + describe('when group has a description', () => { + it('renders description', () => { + const descriptionHtml = '<p>Foo bar</p>'; + + createComponent({ + propsData: { + group: { + ...group, + descriptionHtml, + }, + }, + }); + + expect(findGroupDescription().element.innerHTML).toBe(descriptionHtml); + }); + }); + + describe('when group does not have a description', () => { + it('does not render description', () => { + createComponent({ + propsData: { + group: { + ...group, + descriptionHtml: null, + }, + }, + }); + + expect(findGroupDescription().exists()).toBe(false); + }); + }); + + describe('when `showGroupIcon` prop is `true`', () => { + describe('when `parent` attribute is `null`', () => { + it('shows group icon', () => { + createComponent({ propsData: { showGroupIcon: true } }); + + expect(wrapper.findByTestId('group-icon').exists()).toBe(true); + }); + }); + + describe('when `parent` attribute is set', () => { + it('shows subgroup icon', () => { + createComponent({ + propsData: { + showGroupIcon: true, + group: { + ...group, + parent: { + id: 'gid://gitlab/Group/35', + }, + }, + }, + }); + + expect(wrapper.findByTestId('subgroup-icon').exists()).toBe(true); + }); + }); + }); + + describe('when `showGroupIcon` prop is `false`', () => { + it('does not show group icon', () => { + createComponent(); + + expect(wrapper.findByTestId('group-icon').exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/groups_list/groups_list_spec.js b/spec/frontend/vue_shared/components/groups_list/groups_list_spec.js new file mode 100644 index 00000000000..c65aa347bcf --- /dev/null +++ b/spec/frontend/vue_shared/components/groups_list/groups_list_spec.js @@ -0,0 +1,34 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import GroupsList from '~/vue_shared/components/groups_list/groups_list.vue'; +import GroupsListItem from '~/vue_shared/components/groups_list/groups_list_item.vue'; +import { groups } from './mock_data'; + +describe('GroupsList', () => { + let wrapper; + + const defaultPropsData = { + groups, + }; + + const createComponent = () => { + wrapper = shallowMountExtended(GroupsList, { + propsData: defaultPropsData, + }); + }; + + it('renders list with `GroupsListItem` component', () => { + createComponent(); + + const groupsListItemWrappers = wrapper.findAllComponents(GroupsListItem).wrappers; + const expectedProps = groupsListItemWrappers.map((groupsListItemWrapper) => + groupsListItemWrapper.props(), + ); + + expect(expectedProps).toEqual( + defaultPropsData.groups.map((group) => ({ + group, + showGroupIcon: false, + })), + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/groups_list/mock_data.js b/spec/frontend/vue_shared/components/groups_list/mock_data.js new file mode 100644 index 00000000000..0dad27f8311 --- /dev/null +++ b/spec/frontend/vue_shared/components/groups_list/mock_data.js @@ -0,0 +1,35 @@ +export const groups = [ + { + id: 1, + fullName: 'Gitlab Org', + parent: null, + webUrl: 'http://127.0.0.1:3000/groups/gitlab-org', + descriptionHtml: + '<p data-sourcepos="1:1-1:64" dir="auto">Dolorem dolorem omnis impedit cupiditate pariatur officia velit. Fusce eget orci a ipsum tempus vehicula. Donec rhoncus ante sed lacus pharetra, vitae imperdiet felis lobortis. Donec maximus dapibus orci, sit amet euismod dolor rhoncus vel. In nec mauris nibh.</p>', + avatarUrl: null, + descendantGroupsCount: 1, + projectsCount: 1, + groupMembersCount: 2, + visibility: 'internal', + accessLevel: { + integerValue: 10, + }, + }, + { + id: 2, + fullName: 'Gitlab Org / test subgroup', + parent: { + id: 1, + }, + webUrl: 'http://127.0.0.1:3000/groups/gitlab-org/test-subgroup', + descriptionHtml: '', + avatarUrl: null, + descendantGroupsCount: 4, + projectsCount: 4, + groupMembersCount: 4, + visibility: 'private', + accessLevel: { + integerValue: 20, + }, + }, +]; diff --git a/spec/frontend/vue_shared/components/help_popover_spec.js b/spec/frontend/vue_shared/components/help_popover_spec.js index 76e66d07fa0..e39061476b4 100644 --- a/spec/frontend/vue_shared/components/help_popover_spec.js +++ b/spec/frontend/vue_shared/components/help_popover_spec.js @@ -74,6 +74,22 @@ describe('HelpPopover', () => { }); }); + describe('with trigger classes', () => { + it.each` + triggerClass + ${'class-a class-b'} + ${['class-a', 'class-b']} + ${{ 'class-a': true, 'class-b': true }} + `('renders button with classes given $triggerClass', ({ triggerClass }) => { + createComponent({ + props: { triggerClass }, + }); + + expect(findQuestionButton().classes('class-a')).toBe(true); + expect(findQuestionButton().classes('class-b')).toBe(true); + }); + }); + describe('with other options', () => { const placement = 'bottom'; diff --git a/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js b/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js index b782a2b19da..141c3aa7da6 100644 --- a/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js +++ b/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js @@ -96,6 +96,15 @@ describe('ListboxInput', () => { expect(findGlListbox().props('fluidWidth')).toBe(fluidWidth); }); + it.each(['class-a class-b', ['class-a', 'class-b'], { 'class-a': true, 'class-b': true }])( + 'passes %s class to listbox', + (toggleClass) => { + createComponent({ toggleClass }); + + expect(findGlListbox().props('toggleClass')).toBe(toggleClass); + }, + ); + it.each(['right', 'left'])("passes %s to the listbox's placement prop", (placement) => { createComponent({ placement }); diff --git a/spec/frontend/vue_shared/components/markdown/comment_templates_dropdown_spec.js b/spec/frontend/vue_shared/components/markdown/comment_templates_dropdown_spec.js index 2bef6dd15df..cd9f27dccbd 100644 --- a/spec/frontend/vue_shared/components/markdown/comment_templates_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/markdown/comment_templates_dropdown_spec.js @@ -1,11 +1,18 @@ +import { GlCollapsibleListbox } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import savedRepliesResponse from 'test_fixtures/graphql/comment_templates/saved_replies.query.graphql.json'; +import { mockTracking } from 'helpers/tracking_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; import CommentTemplatesDropdown from '~/vue_shared/components/markdown/comment_templates_dropdown.vue'; import savedRepliesQuery from '~/vue_shared/components/markdown/saved_replies.query.graphql'; +import { + TRACKING_SAVED_REPLIES_USE, + TRACKING_SAVED_REPLIES_USE_IN_MR, +} from '~/vue_shared/components/markdown/constants'; let wrapper; let savedRepliesResp; @@ -31,19 +38,24 @@ function createComponent(options = {}) { }); } -describe('Comment templates dropdown', () => { - it('fetches data when dropdown gets opened', async () => { - const mockApollo = createMockApolloProvider(savedRepliesResponse); - wrapper = createComponent({ mockApollo }); +function findDropdownComponent() { + return wrapper.findComponent(GlCollapsibleListbox); +} - wrapper.find('.js-comment-template-toggle').trigger('click'); +async function selectSavedReply() { + const dropdown = findDropdownComponent(); - await waitForPromises(); + dropdown.vm.$emit('shown'); - expect(savedRepliesResp).toHaveBeenCalled(); - }); + await waitForPromises(); + + dropdown.vm.$emit('select', savedRepliesResponse.data.currentUser.savedReplies.nodes[0].id); +} + +useMockLocationHelper(); - it('adds emits a select event on selecting a comment', async () => { +describe('Comment templates dropdown', () => { + it('fetches data when dropdown gets opened', async () => { const mockApollo = createMockApolloProvider(savedRepliesResponse); wrapper = createComponent({ mockApollo }); @@ -51,8 +63,67 @@ describe('Comment templates dropdown', () => { await waitForPromises(); - wrapper.find('.gl-new-dropdown-item').trigger('click'); + expect(savedRepliesResp).toHaveBeenCalled(); + }); - expect(wrapper.emitted().select[0]).toEqual(['Saved Reply Content']); + describe('when selecting a comment', () => { + let trackingSpy; + let mockApollo; + + beforeEach(() => { + trackingSpy = mockTracking(undefined, window.document, jest.spyOn); + mockApollo = createMockApolloProvider(savedRepliesResponse); + wrapper = createComponent({ mockApollo }); + }); + + it('emits a select event', async () => { + wrapper.find('.js-comment-template-toggle').trigger('click'); + + await waitForPromises(); + + wrapper.find('.gl-new-dropdown-item').trigger('click'); + + expect(wrapper.emitted().select[0]).toEqual(['Saved Reply Content']); + }); + + describe('tracking', () => { + it('tracks overall usage', async () => { + await selectSavedReply(); + + expect(trackingSpy).toHaveBeenCalledWith( + expect.any(String), + TRACKING_SAVED_REPLIES_USE, + expect.any(Object), + ); + }); + + describe('MR-specific usage event', () => { + it('is sent when in an MR', async () => { + window.location.toString.mockReturnValue('this/looks/like/a/-/merge_requests/1'); + + await selectSavedReply(); + + expect(trackingSpy).toHaveBeenCalledWith( + expect.any(String), + TRACKING_SAVED_REPLIES_USE_IN_MR, + expect.any(Object), + ); + expect(trackingSpy).toHaveBeenCalledTimes(2); + }); + + it('is not sent when not in an MR', async () => { + window.location.toString.mockReturnValue('this/looks/like/a/-/issues/1'); + + await selectSavedReply(); + + expect(trackingSpy).not.toHaveBeenCalledWith( + expect.any(String), + TRACKING_SAVED_REPLIES_USE_IN_MR, + expect.any(Object), + ); + expect(trackingSpy).toHaveBeenCalledTimes(1); + }); + }); + }); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js index eb728879fb7..40875ed5dbc 100644 --- a/spec/frontend/vue_shared/components/markdown/header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/header_spec.js @@ -46,84 +46,39 @@ describe('Markdown field header component', () => { createWrapper(); }); - describe('markdown header buttons', () => { + describe.each` + i | buttonTitle | nonMacTitle | buttonType + ${0} | ${'Insert suggestion'} | ${'Insert suggestion'} | ${'codeSuggestion'} + ${1} | ${'Add bold text (⌘B)'} | ${'Add bold text (Ctrl+B)'} | ${'bold'} + ${2} | ${'Add italic text (⌘I)'} | ${'Add italic text (Ctrl+I)'} | ${'italic'} + ${3} | ${'Add strikethrough text (⌘⇧X)'} | ${'Add strikethrough text (Ctrl+Shift+X)'} | ${'strike'} + ${4} | ${'Insert a quote'} | ${'Insert a quote'} | ${'blockquote'} + ${5} | ${'Insert code'} | ${'Insert code'} | ${'code'} + ${6} | ${'Add a link (⌘K)'} | ${'Add a link (Ctrl+K)'} | ${'link'} + ${7} | ${'Add a bullet list'} | ${'Add a bullet list'} | ${'bulletList'} + ${8} | ${'Add a numbered list'} | ${'Add a numbered list'} | ${'orderedList'} + ${9} | ${'Add a checklist'} | ${'Add a checklist'} | ${'taskList'} + ${10} | ${'Indent line (⌘])'} | ${'Indent line (Ctrl+])'} | ${'indent'} + ${11} | ${'Outdent line (⌘[)'} | ${'Outdent line (Ctrl+[)'} | ${'outdent'} + ${12} | ${'Add a collapsible section'} | ${'Add a collapsible section'} | ${'details'} + ${13} | ${'Add a table'} | ${'Add a table'} | ${'table'} + ${14} | ${'Attach a file or image'} | ${'Attach a file or image'} | ${'upload'} + ${15} | ${'Go full screen'} | ${'Go full screen'} | ${'fullScreen'} + `('markdown header buttons', ({ i, buttonTitle, nonMacTitle, buttonType }) => { it('renders the buttons with the correct title', () => { - const buttons = [ - 'Insert suggestion', - 'Add bold text (⌘B)', - 'Add italic text (⌘I)', - 'Add strikethrough text (⌘⇧X)', - 'Insert a quote', - 'Insert code', - 'Add a link (⌘K)', - 'Add a bullet list', - 'Add a numbered list', - 'Add a checklist', - 'Indent line (⌘])', - 'Outdent line (⌘[)', - 'Add a collapsible section', - 'Add a table', - 'Go full screen', - ]; - const elements = findToolbarButtons(); - - elements.wrappers.forEach((buttonEl, index) => { - expect(buttonEl.props('buttonTitle')).toBe(buttons[index]); - }); + expect(findToolbarButtons().wrappers[i].props('buttonTitle')).toBe(buttonTitle); }); it('renders correct title on non MacOS systems', () => { - window.gl = { - client: { - isMac: false, - }, - }; + window.gl = { client: { isMac: false } }; createWrapper(); - const buttons = [ - 'Insert suggestion', - 'Add bold text (Ctrl+B)', - 'Add italic text (Ctrl+I)', - 'Add strikethrough text (Ctrl+Shift+X)', - 'Insert a quote', - 'Insert code', - 'Add a link (Ctrl+K)', - 'Add a bullet list', - 'Add a numbered list', - 'Add a checklist', - 'Indent line (Ctrl+])', - 'Outdent line (Ctrl+[)', - 'Add a collapsible section', - 'Add a table', - 'Go full screen', - ]; - const elements = findToolbarButtons(); - - elements.wrappers.forEach((buttonEl, index) => { - expect(buttonEl.props('buttonTitle')).toBe(buttons[index]); - }); - }); - - it('renders "Attach a file or image" button using gl-button', () => { - const button = wrapper.findByTestId('button-attach-file'); - - expect(button.element.tagName).toBe('GL-BUTTON-STUB'); - expect(button.attributes('title')).toBe('Attach a file or image'); + expect(findToolbarButtons().wrappers[i].props('buttonTitle')).toBe(nonMacTitle); }); - describe('when the user is on a non-Mac', () => { - beforeEach(() => { - delete window.gl.client.isMac; - - createWrapper(); - }); - - it('renders keyboard shortcuts with Ctrl+ instead of ⌘', () => { - const boldButton = findToolbarButtonByProp('icon', 'bold'); - - expect(boldButton.props('buttonTitle')).toBe('Add bold text (Ctrl+B)'); - }); + it('passes button type to `trackingProperty` prop', () => { + expect(findToolbarButtons().wrappers[i].props('trackingProperty')).toBe(buttonType); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js index 33e9d6add99..54510bf043d 100644 --- a/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js +++ b/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js @@ -1,6 +1,10 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import ToolbarButton from '~/vue_shared/components/markdown/toolbar_button.vue'; +import { + TOOLBAR_CONTROL_TRACKING_ACTION, + MARKDOWN_EDITOR_TRACKING_LABEL, +} from '~/vue_shared/components/markdown/tracking'; describe('toolbar_button', () => { let wrapper; @@ -20,9 +24,8 @@ describe('toolbar_button', () => { }); }; - const getButtonShortcutsAttr = () => { - return wrapper.findComponent(GlButton).attributes('data-md-shortcuts'); - }; + const findToolbarButton = () => wrapper.findComponent(GlButton); + const getButtonShortcutsAttr = () => findToolbarButton().attributes('data-md-shortcuts'); describe('keyboard shortcuts', () => { it.each` @@ -40,4 +43,24 @@ describe('toolbar_button', () => { }, ); }); + + it('adds tracking attributes to the button when `trackingProperty` prop is defined', () => { + const buttonType = 'bold'; + + createComponent({ trackingProperty: buttonType }); + + expect(findToolbarButton().attributes('data-track-action')).toBe( + TOOLBAR_CONTROL_TRACKING_ACTION, + ); + expect(findToolbarButton().attributes('data-track-label')).toBe(MARKDOWN_EDITOR_TRACKING_LABEL); + expect(findToolbarButton().attributes('data-track-property')).toBe(buttonType); + }); + + it('does not add tracking attributes to the button when `trackingProperty` prop is undefined', () => { + createComponent(); + + ['data-track-action', 'data-track-label', 'data-track-property'].forEach((dataAttribute) => { + expect(findToolbarButton().attributes(dataAttribute)).toBeUndefined(); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js index 5bf11ff2b26..90d8ce3b500 100644 --- a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js +++ b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js @@ -3,6 +3,7 @@ import Toolbar from '~/vue_shared/components/markdown/toolbar.vue'; import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue'; import { updateText } from '~/lib/utils/text_markdown'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; jest.mock('~/lib/utils/text_markdown'); @@ -98,7 +99,7 @@ describe('toolbar', () => { expect.objectContaining({ tag: `### Rich text editor -Try out **styling** _your_ content right here or read the [direction](https://about.gitlab.com/direction/plan/knowledge/content_editor/).`, +Try out **styling** _your_ content right here or read the [direction](${PROMO_URL}/direction/plan/knowledge/content_editor/).`, textArea: document.querySelector('textarea'), cursorOffset: 0, wrap: false, diff --git a/spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js b/spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js index 4b0b89fe1e7..36f5517decf 100644 --- a/spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js +++ b/spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js @@ -2,6 +2,7 @@ import { GlFormInput, GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import merge from 'lodash/merge'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import MetricImagesTable from '~/vue_shared/components/metric_images/metric_images_table.vue'; import MetricImagesTab from '~/vue_shared/components/metric_images/metric_images_tab.vue'; diff --git a/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js b/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js index 12dca95e9ba..ca141f53bf1 100644 --- a/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js +++ b/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js @@ -2,6 +2,7 @@ import { GlLink, GlModal } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; import Vue from 'vue'; import merge from 'lodash/merge'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import createStore from '~/vue_shared/components/metric_images/store'; import MetricsImageTable from '~/vue_shared/components/metric_images/metric_images_table.vue'; diff --git a/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js b/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js index 626f6fc735e..544466a22ca 100644 --- a/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js +++ b/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import actionsFactory from '~/vue_shared/components/metric_images/store/actions'; import * as types from '~/vue_shared/components/metric_images/store/mutation_types'; diff --git a/spec/frontend/vue_shared/components/modal_copy_button_spec.js b/spec/frontend/vue_shared/components/modal_copy_button_spec.js index 2f8f97c5b95..7f3cf9820db 100644 --- a/spec/frontend/vue_shared/components/modal_copy_button_spec.js +++ b/spec/frontend/vue_shared/components/modal_copy_button_spec.js @@ -27,16 +27,19 @@ describe('modal copy button', () => { wrapper.trigger('click'); await nextTick(); - expect(wrapper.emitted().success).not.toBeEmpty(); + expect(wrapper.emitted('error')).toBeUndefined(); + expect(wrapper.emitted('success')).toHaveLength(1); expect(document.execCommand).toHaveBeenCalledWith('copy'); expect(root.emitted(BV_HIDE_TOOLTIP)).toEqual([['test-id']]); }); + it("should propagate the clipboard error event if execCommand doesn't work", async () => { document.execCommand = jest.fn(() => false); wrapper.trigger('click'); await nextTick(); - expect(wrapper.emitted().error).not.toBeEmpty(); + expect(wrapper.emitted('success')).toBeUndefined(); + expect(wrapper.emitted('error')).toHaveLength(1); expect(document.execCommand).toHaveBeenCalledWith('copy'); }); }); diff --git a/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js b/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js index f04e1976a5f..7efc0e162b8 100644 --- a/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js @@ -138,7 +138,7 @@ describe('NewResourceDropdown component', () => { }); it('dropdown button is not a link', () => { - expect(findDropdown().attributes('split-href')).toBeUndefined(); + expect(findDropdown().props('splitHref')).toBe(''); }); it('displays default text on the dropdown button', () => { @@ -162,7 +162,7 @@ describe('NewResourceDropdown component', () => { it('dropdown button is a link', () => { const href = joinPaths(project1.webUrl, DASH_SCOPE, expectedPath); - expect(findDropdown().attributes('split-href')).toBe(href); + expect(findDropdown().props('splitHref')).toBe(href); }); it('displays project name on the dropdown button', () => { @@ -199,7 +199,7 @@ describe('NewResourceDropdown component', () => { await nextTick(); const dropdown = findDropdown(); - expect(dropdown.attributes('split-href')).toBe( + expect(dropdown.props('splitHref')).toBe( joinPaths(project1.webUrl, DASH_SCOPE, 'issues/new'), ); expect(dropdown.props('text')).toBe(`New issue in ${project1.name}`); @@ -217,7 +217,7 @@ describe('NewResourceDropdown component', () => { await nextTick(); const dropdown = findDropdown(); - expect(dropdown.attributes('split-href')).toBe( + expect(dropdown.props('splitHref')).toBe( joinPaths(project1.webUrl, DASH_SCOPE, 'issues/new'), ); expect(dropdown.props('text')).toBe(`New issue in ${project1.name}`); 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 7e669fb7c71..6d4745e8e3d 100644 --- a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js @@ -1,5 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import IssuePlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; import { userDataMock } from 'jest/notes/mock_data'; diff --git a/spec/frontend/vue_shared/components/page_size_selector_spec.js b/spec/frontend/vue_shared/components/page_size_selector_spec.js index fce7ceee2fe..ecb25fa7468 100644 --- a/spec/frontend/vue_shared/components/page_size_selector_spec.js +++ b/spec/frontend/vue_shared/components/page_size_selector_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import PageSizeSelector, { PAGE_SIZES } from '~/vue_shared/components/page_size_selector.vue'; @@ -11,30 +11,30 @@ describe('Page size selector component', () => { }); }; - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); - it.each(PAGE_SIZES)('shows expected text in the dropdown button for page size %s', (pageSize) => { - createWrapper({ pageSize }); + it.each(PAGE_SIZES)('shows expected text in the listbox button for page size %s', (pageSize) => { + createWrapper({ pageSize: pageSize.value }); - expect(findDropdown().props('text')).toBe(`Show ${pageSize} items`); + expect(findListbox().props('toggleText')).toBe(`Show ${pageSize.value} items`); }); - it('shows the expected dropdown items', () => { + it('shows the expected listbox items', () => { createWrapper(); + const options = findListbox().props('items'); + PAGE_SIZES.forEach((pageSize, index) => { - expect(findDropdownItems().at(index).text()).toBe(`Show ${pageSize} items`); + expect(options[index].text).toBe(pageSize.text); }); }); - it('will emit the new page size when a dropdown item is clicked', () => { + it('will emit the new page size when a listbox item is clicked', () => { createWrapper(); - findDropdownItems().wrappers.forEach((itemWrapper, index) => { - itemWrapper.vm.$emit('click'); - - expect(wrapper.emitted('input')[index][0]).toBe(PAGE_SIZES[index]); + PAGE_SIZES.forEach((pageSize, index) => { + findListbox().vm.$emit('select', pageSize.value); + expect(wrapper.emitted('input')[index][0]).toBe(PAGE_SIZES[index].value); }); }); }); diff --git a/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js index 0e387d1c139..2490422e4e8 100644 --- a/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js +++ b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js @@ -1,7 +1,10 @@ -import { GlAvatarLabeled, GlBadge, GlIcon, GlPopover } from '@gitlab/ui'; +import { GlAvatarLabeled, GlBadge, GlIcon, GlPopover, GlDisclosureDropdown } from '@gitlab/ui'; +import uniqueId from 'lodash/uniqueId'; import projects from 'test_fixtures/api/users/projects/get.json'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { __ } from '~/locale'; import ProjectsListItem from '~/vue_shared/components/projects_list/projects_list_item.vue'; +import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/projects_list/constants'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { @@ -13,8 +16,9 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge. import { ACCESS_LEVEL_LABELS } from '~/access_level/constants'; import { FEATURABLE_DISABLED, FEATURABLE_ENABLED } from '~/featurable/constants'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import DeleteModal from '~/projects/components/shared/delete_modal.vue'; -jest.mock('lodash/uniqueId', () => (prefix) => `${prefix}1`); +jest.mock('lodash/uniqueId'); describe('ProjectsListItem', () => { let wrapper; @@ -40,6 +44,10 @@ describe('ProjectsListItem', () => { const findProjectDescription = () => wrapper.findByTestId('project-description'); const findVisibilityIcon = () => findAvatarLabeled().findComponent(GlIcon); + beforeEach(() => { + uniqueId.mockImplementation(jest.requireActual('lodash/uniqueId')); + }); + it('renders project avatar', () => { createComponent(); @@ -207,6 +215,10 @@ describe('ProjectsListItem', () => { }); describe('if project has topics', () => { + beforeEach(() => { + uniqueId.mockImplementation((prefix) => `${prefix}1`); + }); + it('renders first three topics', () => { createComponent(); @@ -306,4 +318,72 @@ describe('ProjectsListItem', () => { expect(wrapper.findByTestId('project-icon').exists()).toBe(false); }); }); + + describe('when project has actions', () => { + const editPath = '/foo/bar/edit'; + + beforeEach(() => { + createComponent({ + propsData: { + project: { + ...project, + actions: [ACTION_EDIT, ACTION_DELETE], + isForked: true, + editPath, + }, + }, + }); + }); + + it('displays actions dropdown', () => { + expect(wrapper.findComponent(GlDisclosureDropdown).props()).toMatchObject({ + items: [ + { + id: ACTION_EDIT, + text: __('Edit'), + href: editPath, + }, + { + id: ACTION_DELETE, + text: __('Delete'), + extraAttrs: { + class: 'gl-text-red-500!', + }, + action: expect.any(Function), + }, + ], + }); + }); + + describe('when delete action is fired', () => { + beforeEach(() => { + wrapper + .findComponent(GlDisclosureDropdown) + .props('items') + .find((item) => item.id === ACTION_DELETE) + .action(); + }); + + it('displays confirmation modal with correct props', () => { + expect(wrapper.findComponent(DeleteModal).props()).toMatchObject({ + visible: true, + confirmPhrase: project.name, + isFork: true, + issuesCount: '0', + forksCount: '0', + starsCount: '0', + }); + }); + + describe('when deletion is confirmed', () => { + beforeEach(() => { + wrapper.findComponent(DeleteModal).vm.$emit('primary'); + }); + + it('emits `delete` event', () => { + expect(wrapper.emitted('delete')).toMatchObject([[project]]); + }); + }); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js b/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js index a0adbb89894..fb195dfe08e 100644 --- a/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js +++ b/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js @@ -32,4 +32,18 @@ describe('ProjectsList', () => { })), ); }); + + describe('when `ProjectListItem` emits `delete` event', () => { + const [firstProject] = defaultPropsData.projects; + + beforeEach(() => { + createComponent(); + + wrapper.findComponent(ProjectsListItem).vm.$emit('delete', firstProject); + }); + + it('emits `delete` event', () => { + expect(wrapper.emitted('delete')).toEqual([[firstProject]]); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js b/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js index b93fa37546f..400be4ad131 100644 --- a/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js +++ b/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js @@ -1,5 +1,5 @@ -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import component from '~/vue_shared/components/registry/persisted_dropdown_selection.vue'; @@ -16,7 +16,7 @@ describe('Persisted dropdown selection', () => { }; function createComponent({ props = {}, data = {} } = {}) { - wrapper = shallowMount(component, { + wrapper = mount(component, { propsData: { ...defaultProps, ...props, @@ -28,8 +28,10 @@ describe('Persisted dropdown selection', () => { } const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findGlCollapsibleListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const findGlListboxItems = () => wrapper.findAllComponents(GlListboxItem); + const findGlListboxToggleText = () => + findGlCollapsibleListbox().find('.gl-new-dropdown-button-text'); describe('local storage sync', () => { it('uses the local storage sync component with the correct props', () => { @@ -63,20 +65,22 @@ describe('Persisted dropdown selection', () => { it('has a dropdown component', () => { createComponent(); - expect(findDropdown().exists()).toBe(true); + expect(findGlCollapsibleListbox().exists()).toBe(true); }); describe('dropdown text', () => { it('when no selection shows the first', () => { createComponent(); - expect(findDropdown().props('text')).toBe('Maven'); + expect(findGlListboxToggleText().text()).toBe('Maven'); }); - it('when an option is selected, shows that option label', () => { - createComponent({ data: { selected: defaultProps.options[1].value } }); + it('when an option is selected, shows that option label', async () => { + createComponent(); + findGlCollapsibleListbox().vm.$emit('select', defaultProps.options[1].value); + await nextTick(); - expect(findDropdown().props('text')).toBe('Gradle'); + expect(findGlListboxToggleText().text()).toBe('Gradle'); }); }); @@ -84,34 +88,20 @@ describe('Persisted dropdown selection', () => { it('has one item for each option', () => { createComponent(); - expect(findDropdownItems()).toHaveLength(defaultProps.options.length); - }); - - it('binds the correct props', () => { - createComponent({ data: { selected: defaultProps.options[0].value } }); - - expect(findDropdownItems().at(0).props()).toMatchObject({ - isChecked: true, - isCheckItem: true, - }); - - expect(findDropdownItems().at(1).props()).toMatchObject({ - isChecked: false, - isCheckItem: true, - }); + expect(findGlListboxItems()).toHaveLength(defaultProps.options.length); }); it('on click updates the data and emits event', async () => { - createComponent({ data: { selected: defaultProps.options[0].value } }); - expect(findDropdownItems().at(0).props('isChecked')).toBe(true); + createComponent(); + const selectedItem = 'gradle'; - findDropdownItems().at(1).vm.$emit('click'); + expect(findGlCollapsibleListbox().props('selected')).toBe('maven'); + findGlCollapsibleListbox().vm.$emit('select', selectedItem); await nextTick(); - expect(wrapper.emitted('change')).toStrictEqual([['gradle']]); - expect(findDropdownItems().at(0).props('isChecked')).toBe(false); - expect(findDropdownItems().at(1).props('isChecked')).toBe(true); + expect(wrapper.emitted('change').at(-1)).toStrictEqual([selectedItem]); + expect(findGlCollapsibleListbox().props('selected')).toBe(selectedItem); }); }); }); diff --git a/spec/frontend/vue_shared/components/registry/registry_search_spec.js b/spec/frontend/vue_shared/components/registry/registry_search_spec.js index 59bb0646350..f86406d05cb 100644 --- a/spec/frontend/vue_shared/components/registry/registry_search_spec.js +++ b/spec/frontend/vue_shared/components/registry/registry_search_spec.js @@ -25,6 +25,8 @@ describe('Registry Search', () => { orderBy: 'name', search: [], sort: 'asc', + after: null, + before: null, }; const mountComponent = (propsData = defaultProps) => { diff --git a/spec/frontend/vue_shared/components/registry/title_area_spec.js b/spec/frontend/vue_shared/components/registry/title_area_spec.js index ec1451de470..138027be0cc 100644 --- a/spec/frontend/vue_shared/components/registry/title_area_spec.js +++ b/spec/frontend/vue_shared/components/registry/title_area_spec.js @@ -1,21 +1,16 @@ import { GlAvatar, GlSprintf, GlLink, GlSkeletonLoader } from '@gitlab/ui'; -import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import component from '~/vue_shared/components/registry/title_area.vue'; describe('title area', () => { let wrapper; - const DYNAMIC_SLOT = 'metadata-dynamic-slot'; - const findSubHeaderSlot = () => wrapper.findByTestId('sub-header'); const findRightActionsSlot = () => wrapper.findByTestId('right-actions'); const findMetadataSlot = (name) => wrapper.findByTestId(name); const findTitle = () => wrapper.findByTestId('title'); const findAvatar = () => wrapper.findComponent(GlAvatar); const findInfoMessages = () => wrapper.findAllByTestId('info-message'); - const findDynamicSlot = () => wrapper.findByTestId(DYNAMIC_SLOT); - const findSlotOrderElements = () => wrapper.findAll('[slot-test]'); const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const mountComponent = ({ propsData = { title: 'foo' }, slots } = {}) => { @@ -93,19 +88,17 @@ describe('title area', () => { `('$slotNames metadata slots', ({ slotNames }) => { const slots = generateSlotMocks(slotNames); - it('exist when the slot is present', async () => { + it('exist when the slot is present', () => { mountComponent({ slots }); - await nextTick(); slotNames.forEach((name) => { expect(findMetadataSlot(name).exists()).toBe(true); }); }); - it('is/are hidden when metadata-loading is true', async () => { + it('is/are hidden when metadata-loading is true', () => { mountComponent({ slots, propsData: { title: 'foo', metadataLoading: true } }); - await nextTick(); slotNames.forEach((name) => { expect(findMetadataSlot(name).exists()).toBe(false); }); @@ -115,67 +108,19 @@ describe('title area', () => { describe('metadata skeleton loader', () => { const slots = generateSlotMocks(['metadata-foo']); - it('is hidden when metadata loading is false', async () => { + it('is hidden when metadata loading is false', () => { mountComponent({ slots }); - await nextTick(); - expect(findSkeletonLoader().exists()).toBe(false); }); - it('is shown when metadata loading is true', async () => { + it('is shown when metadata loading is true', () => { mountComponent({ propsData: { metadataLoading: true }, slots }); - await nextTick(); - expect(findSkeletonLoader().exists()).toBe(true); }); }); - describe('dynamic slots', () => { - const createDynamicSlot = () => { - return wrapper.vm.$createElement('div', { - attrs: { - 'data-testid': DYNAMIC_SLOT, - 'slot-test': true, - }, - }); - }; - - it('shows dynamic slots', async () => { - mountComponent(); - // we manually add a new slot to simulate dynamic slots being evaluated after the initial mount - wrapper.vm.$slots[DYNAMIC_SLOT] = createDynamicSlot(); - - // updating the slots like we do on line 141 does not cause the updated lifecycle-hook to be triggered - wrapper.vm.$forceUpdate(); - await nextTick(); - - expect(findDynamicSlot().exists()).toBe(true); - }); - - it('preserve the order of the slots', async () => { - mountComponent({ - slots: { - 'metadata-foo': '<div slot-test data-testid="metadata-foo"></div>', - }, - }); - - // rewrite slot putting dynamic slot as first - wrapper.vm.$slots = { - 'metadata-dynamic-slot': createDynamicSlot(), - 'metadata-foo': wrapper.vm.$slots['metadata-foo'], - }; - - // updating the slots like we do on line 159 does not cause the updated lifecycle-hook to be triggered - wrapper.vm.$forceUpdate(); - await nextTick(); - - expect(findSlotOrderElements().at(0).attributes('data-testid')).toBe(DYNAMIC_SLOT); - expect(findSlotOrderElements().at(1).attributes('data-testid')).toBe('metadata-foo'); - }); - }); - describe('info-messages', () => { it('shows a message when the props contains one', () => { mountComponent({ propsData: { infoMessages: [{ text: 'foo foo bar bar' }] } }); diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js index 6b711b6b6b2..431ede17954 100644 --- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js @@ -7,15 +7,22 @@ import LineHighlighter from '~/blob/line_highlighter'; import addBlobLinksTracking from '~/blob/blob_links_tracking'; import { BLOB_DATA_MOCK, CHUNK_1, CHUNK_2, LANGUAGE_MOCK } from './mock_data'; -jest.mock('~/blob/line_highlighter'); +const lineHighlighter = new LineHighlighter(); +jest.mock('~/blob/line_highlighter', () => + jest.fn().mockReturnValue({ + highlightHash: jest.fn(), + }), +); jest.mock('~/blob/blob_links_tracking'); describe('Source Viewer component', () => { let wrapper; const CHUNKS_MOCK = [CHUNK_1, CHUNK_2]; + const hash = '#L142'; const createComponent = () => { wrapper = shallowMountExtended(SourceViewer, { + mocks: { $route: { hash } }, propsData: { blob: BLOB_DATA_MOCK, chunks: CHUNKS_MOCK }, }); }; @@ -48,4 +55,10 @@ describe('Source Viewer component', () => { expect(findChunks().at(1).props()).toMatchObject(CHUNK_2); }); }); + + describe('hash highlighting', () => { + it('calls highlightHash with expected parameter', () => { + expect(lineHighlighter.highlightHash).toHaveBeenCalledWith(hash); + }); + }); }); 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 6b1d65c5a6a..a486d13a856 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 @@ -1,6 +1,8 @@ import hljs from 'highlight.js/lib/core'; import Vue from 'vue'; import VueRouter from 'vue-router'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; 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'; @@ -14,7 +16,9 @@ import { LEGACY_FALLBACKS, CODEOWNERS_FILE_NAME, CODEOWNERS_LANGUAGE, + SVELTE_LANGUAGE, } from '~/vue_shared/components/source_viewer/constants'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import waitForPromises from 'helpers/wait_for_promises'; import LineHighlighter from '~/blob/line_highlighter'; import eventHub from '~/notes/event_hub'; @@ -25,6 +29,7 @@ jest.mock('highlight.js/lib/core'); jest.mock('~/vue_shared/components/source_viewer/plugins/index'); Vue.use(VueRouter); const router = new VueRouter(); +const mockAxios = new MockAdapter(axios); const generateContent = (content, totalLines = 1, delimiter = '\n') => { let generatedContent = ''; @@ -71,6 +76,42 @@ describe('Source Viewer component', () => { return createComponent(); }); + describe('Displaying LFS blob', () => { + const rawPath = '/org/project/-/raw/file.xml'; + const externalStorageUrl = 'http://127.0.0.1:9000/lfs-objects/91/12/1341234'; + const rawTextBlob = 'This is the external content'; + const blob = { + storedExternally: true, + externalStorage: 'lfs', + simpleViewer: { fileType: 'text' }, + rawPath, + }; + + afterEach(() => { + mockAxios.reset(); + }); + + it('Uses externalStorageUrl to fetch content if present', async () => { + mockAxios.onGet(externalStorageUrl).replyOnce(HTTP_STATUS_OK, rawTextBlob); + + await createComponent({ ...blob, externalStorageUrl }); + + expect(mockAxios.history.get).toHaveLength(1); + expect(mockAxios.history.get[0].url).toBe(externalStorageUrl); + expect(wrapper.vm.$data.content).toBe(rawTextBlob); + }); + + it('Falls back to rawPath to fetch content', async () => { + mockAxios.onGet(rawPath).replyOnce(HTTP_STATUS_OK, rawTextBlob); + + await createComponent(blob); + + expect(mockAxios.history.get).toHaveLength(1); + expect(mockAxios.history.get[0].url).toBe(rawPath); + expect(wrapper.vm.$data.content).toBe(rawTextBlob); + }); + }); + describe('event tracking', () => { it('fires a tracking event when the component is created', () => { const eventData = { label: EVENT_LABEL_VIEWER, property: language }; @@ -120,6 +161,33 @@ describe('Source Viewer component', () => { ); }); + describe('sub-languages', () => { + const languageDefinition = { + subLanguage: 'xml', + contains: [{ subLanguage: 'javascript' }, { subLanguage: 'typescript' }], + }; + + beforeEach(async () => { + jest.spyOn(hljs, 'getLanguage').mockReturnValue(languageDefinition); + createComponent(); + await waitForPromises(); + }); + + it('registers the primary sub-language', () => { + expect(hljs.registerLanguage).toHaveBeenCalledWith( + languageDefinition.subLanguage, + expect.any(Function), + ); + }); + + it.each(languageDefinition.contains)( + 'registers the rest of the sub-languages', + ({ subLanguage }) => { + expect(hljs.registerLanguage).toHaveBeenCalledWith(subLanguage, expect.any(Function)); + }, + ); + }); + 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`); @@ -146,6 +214,18 @@ describe('Source Viewer component', () => { ); }); + it('registers svelte language definition if file name ends with .svelte', async () => { + await createComponent({ name: `component.${SVELTE_LANGUAGE}` }); + const languageDefinition = await import( + '~/vue_shared/components/source_viewer/languages/svelte' + ); + + expect(hljs.registerLanguage).toHaveBeenCalledWith( + SVELTE_LANGUAGE, + languageDefinition.default, + ); + }); + it('highlights the first chunk', () => { expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage }); expect(findChunks().at(0).props('isFirstChunk')).toBe(true); diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js index b6c22ceaa23..56d89d428f7 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -1,15 +1,20 @@ -import { GlModal } from '@gitlab/ui'; +import { GlModal, GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui'; +import { omit } from 'lodash'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import getWritableForksResponse from 'test_fixtures/graphql/vue_shared/components/web_ide/get_writable_forks.query.graphql_none.json'; -import ActionsButton from '~/vue_shared/components/actions_button.vue'; import WebIdeLink, { i18n } from '~/vue_shared/components/web_ide_link.vue'; import ConfirmForkModal from '~/vue_shared/components/web_ide/confirm_fork_modal.vue'; -import { stubComponent } from 'helpers/stub_component'; -import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { stubComponent } from 'helpers/stub_component'; +import { mockTracking } from 'helpers/tracking_helper'; +import { + shallowMountExtended, + mountExtended, + extendedWrapper, +} from 'helpers/vue_test_utils_helper'; import { visitUrl } from '~/lib/utils/url_utility'; import getWritableForksQuery from '~/vue_shared/components/web_ide/get_writable_forks.query.graphql'; @@ -26,13 +31,15 @@ const forkPath = '/some/fork/path'; const ACTION_EDIT = { href: TEST_EDIT_URL, - key: 'edit', + handle: undefined, text: 'Edit single file', secondaryText: 'Edit this file only.', attrs: { - 'data-qa-selector': 'edit_button', - 'data-track-action': 'click_consolidated_edit', - 'data-track-label': 'edit', + 'data-qa-selector': 'edit_menu_item', + }, + tracking: { + action: 'click_consolidated_edit', + label: 'single_file', }, }; const ACTION_EDIT_CONFIRM_FORK = { @@ -41,15 +48,17 @@ const ACTION_EDIT_CONFIRM_FORK = { handle: expect.any(Function), }; const ACTION_WEB_IDE = { - key: 'webide', secondaryText: i18n.webIdeText, text: 'Web IDE', attrs: { - 'data-qa-selector': 'web_ide_button', - 'data-track-action': 'click_consolidated_edit_ide', - 'data-track-label': 'web_ide', + 'data-qa-selector': 'webide_menu_item', }, + href: undefined, handle: expect.any(Function), + tracking: { + action: 'click_consolidated_edit', + label: 'web_ide', + }, }; const ACTION_WEB_IDE_CONFIRM_FORK = { ...ACTION_WEB_IDE, @@ -58,11 +67,15 @@ const ACTION_WEB_IDE_CONFIRM_FORK = { const ACTION_WEB_IDE_EDIT_FORK = { ...ACTION_WEB_IDE, text: 'Edit fork in Web IDE' }; const ACTION_GITPOD = { href: TEST_GITPOD_URL, - key: 'gitpod', + handle: undefined, secondaryText: 'Launch a ready-to-code development environment for your project.', text: 'Gitpod', attrs: { - 'data-qa-selector': 'gitpod_button', + 'data-qa-selector': 'gitpod_menu_item', + }, + tracking: { + action: 'click_consolidated_edit', + label: 'gitpod', }, }; const ACTION_GITPOD_ENABLE = { @@ -72,11 +85,14 @@ const ACTION_GITPOD_ENABLE = { }; const ACTION_PIPELINE_EDITOR = { href: TEST_PIPELINE_EDITOR_URL, - key: 'pipeline_editor', secondaryText: 'Edit, lint, and visualize your pipeline.', text: 'Edit in pipeline editor', attrs: { - 'data-qa-selector': 'pipeline_editor_button', + 'data-qa-selector': 'pipeline_editor_menu_item', + }, + tracking: { + action: 'click_consolidated_edit', + label: 'pipeline_editor', }, }; @@ -84,6 +100,7 @@ describe('vue_shared/components/web_ide_link', () => { Vue.use(VueApollo); let wrapper; + let trackingSpy; function createComponent(props, { mountFn = shallowMountExtended, slots = {} } = {}) { const fakeApollo = createMockApollo([ @@ -108,16 +125,37 @@ describe('vue_shared/components/web_ide_link', () => { <slot name="modal-footer"></slot> </div>`, }), + GlDisclosureDropdownItem, }, apolloProvider: fakeApollo, }); + + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); } - const findActionsButton = () => wrapper.findComponent(ActionsButton); + const findDisclosureDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findDisclosureDropdownItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); const findModal = () => wrapper.findComponent(GlModal); const findForkConfirmModal = () => wrapper.findComponent(ConfirmForkModal); + const getDropdownItemsAsData = () => + findDisclosureDropdownItems().wrappers.map((item) => { + const extendedWrapperItem = extendedWrapper(item); + const attributes = extendedWrapperItem.attributes(); + const props = extendedWrapperItem.props(); + + return { + text: extendedWrapperItem.findByTestId('action-primary-text').text(), + secondaryText: extendedWrapperItem.findByTestId('action-secondary-text').text(), + href: props.item.href, + handle: props.item.handle, + attrs: { + 'data-qa-selector': attributes['data-qa-selector'], + }, + }; + }); + const omitTrackingParams = (actions) => actions.map((action) => omit(action, 'tracking')); - it.each([ + describe.each([ { props: {}, expectedActions: [ACTION_WEB_IDE, ACTION_EDIT], @@ -207,10 +245,27 @@ describe('vue_shared/components/web_ide_link', () => { props: { showEditButton: false }, expectedActions: [ACTION_WEB_IDE], }, - ])('renders actions with appropriately for given props', ({ props, expectedActions }) => { - createComponent(props); + ])('for a set of props', ({ props, expectedActions }) => { + beforeEach(() => { + createComponent(props); + }); + + it('renders the appropiate actions', () => { + // omit tracking property because it is not included in the dropdown item + expect(getDropdownItemsAsData()).toEqual(omitTrackingParams(expectedActions)); + }); + + describe('when an action is clicked', () => { + it('tracks event', () => { + expectedActions.forEach((action, index) => { + findDisclosureDropdownItems().at(index).vm.$emit('action'); - expect(findActionsButton().props('actions')).toEqual(expectedActions); + expect(trackingSpy).toHaveBeenCalledWith(undefined, action.tracking.action, { + label: action.tracking.label, + }); + }); + }); + }); }); it('bubbles up shown and hidden events triggered by actions button component', () => { @@ -219,17 +274,17 @@ describe('vue_shared/components/web_ide_link', () => { expect(wrapper.emitted('shown')).toBe(undefined); expect(wrapper.emitted('hidden')).toBe(undefined); - findActionsButton().vm.$emit('shown'); - findActionsButton().vm.$emit('hidden'); + findDisclosureDropdown().vm.$emit('shown'); + findDisclosureDropdown().vm.$emit('hidden'); expect(wrapper.emitted('shown')).toHaveLength(1); expect(wrapper.emitted('hidden')).toHaveLength(1); }); - it('exposes a default slot', () => { - const slotContent = 'default slot content'; + it.each(['before-actions', 'after-actions'])('exposes a %s slot', (slot) => { + const slotContent = 'slot content'; - createComponent({}, { slots: { default: slotContent } }); + createComponent({}, { slots: { [slot]: slotContent } }); expect(wrapper.text()).toContain(slotContent); }); @@ -248,13 +303,13 @@ describe('vue_shared/components/web_ide_link', () => { }); it('displays Pipeline Editor as the first action', () => { - expect(findActionsButton().props()).toMatchObject({ - actions: [ACTION_PIPELINE_EDITOR, ACTION_WEB_IDE, ACTION_GITPOD], - }); + expect(getDropdownItemsAsData()).toEqual( + omitTrackingParams([ACTION_PIPELINE_EDITOR, ACTION_WEB_IDE, ACTION_GITPOD]), + ); }); it('when web ide button is clicked it opens in a new tab', async () => { - findActionsButton().props('actions')[1].handle(); + findDisclosureDropdownItems().at(1).props().item.handle(); await nextTick(); expect(visitUrl).toHaveBeenCalledWith(TEST_WEB_IDE_URL, true); }); @@ -289,7 +344,7 @@ describe('vue_shared/components/web_ide_link', () => { ({ props, expectedEventPayload }) => { createComponent({ ...props, needsToFork: true, disableForkModal: true }); - findActionsButton().props('actions')[0].handle(); + findDisclosureDropdownItems().at(0).props().item.handle(); expect(wrapper.emitted('edit')).toEqual([[expectedEventPayload]]); }, @@ -309,7 +364,7 @@ describe('vue_shared/components/web_ide_link', () => { it.each(testActions)('opens the modal when the button is clicked', async ({ props }) => { createComponent({ ...props, needsToFork: true }, { mountFn: mountExtended }); - wrapper.findComponent(ActionsButton).props().actions[0].handle(); + findDisclosureDropdownItems().at(0).props().item.handle(); await nextTick(); await wrapper.findByRole('button', { name: /Web IDE|Edit/im }).trigger('click'); diff --git a/spec/frontend/vue_shared/issuable/create/components/issuable_create_root_spec.js b/spec/frontend/vue_shared/issuable/create/components/issuable_create_root_spec.js index e983519d9fc..03f509a3fa3 100644 --- a/spec/frontend/vue_shared/issuable/create/components/issuable_create_root_spec.js +++ b/spec/frontend/vue_shared/issuable/create/components/issuable_create_root_spec.js @@ -1,8 +1,13 @@ import { mount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; import IssuableCreateRoot from '~/vue_shared/issuable/create/components/issuable_create_root.vue'; import IssuableForm from '~/vue_shared/issuable/create/components/issuable_form.vue'; +Vue.use(VueApollo); + const createComponent = ({ descriptionPreviewPath = '/gitlab-org/gitlab-shell/preview_markdown', descriptionHelpPath = '/help/user/markdown', @@ -16,6 +21,7 @@ const createComponent = ({ labelsFetchPath, labelsManagePath, }, + apolloProvider: createMockApollo(), slots: { title: ` <h1 class="js-create-title">New Issuable</h1> diff --git a/spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js b/spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js index ae2fd5ebffa..338dc80b43e 100644 --- a/spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js +++ b/spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js @@ -2,8 +2,9 @@ import { GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import IssuableForm from '~/vue_shared/issuable/create/components/issuable_form.vue'; -import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import LabelsSelect from '~/sidebar/components/labels/labels_select_vue/labels_select_root.vue'; +import { __ } from '~/locale'; const createComponent = ({ descriptionPreviewPath = '/gitlab-org/gitlab-shell/preview_markdown', @@ -24,7 +25,7 @@ const createComponent = ({ `, }, stubs: { - MarkdownField, + MarkdownEditor, }, }); }; @@ -71,18 +72,20 @@ describe('IssuableForm', () => { expect(descriptionFieldEl.exists()).toBe(true); expect(descriptionFieldEl.find('label').text()).toBe('Description'); - expect(descriptionFieldEl.findComponent(MarkdownField).exists()).toBe(true); - expect(descriptionFieldEl.findComponent(MarkdownField).props()).toMatchObject({ - markdownPreviewPath: wrapper.vm.descriptionPreviewPath, + expect(descriptionFieldEl.findComponent(MarkdownEditor).exists()).toBe(true); + expect(descriptionFieldEl.findComponent(MarkdownEditor).props()).toMatchObject({ + renderMarkdownPath: wrapper.vm.descriptionPreviewPath, markdownDocsPath: wrapper.vm.descriptionHelpPath, - addSpacingClasses: false, - showSuggestPopover: true, - textareaValue: '', + value: '', + formFieldProps: { + ariaLabel: __('Description'), + class: 'rspec-issuable-form-description', + placeholder: __('Write a comment or drag your files here…'), + dataQaSelector: 'issuable_form_description_field', + id: 'issuable-description', + name: 'issuable-description', + }, }); - expect(descriptionFieldEl.find('textarea').exists()).toBe(true); - expect(descriptionFieldEl.find('textarea').attributes('placeholder')).toBe( - 'Write a comment or drag your files here…', - ); }); it('renders labels select field', () => { diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js index 502fa609ebc..77333a878d1 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js @@ -15,6 +15,8 @@ const createComponent = ({ showCheckbox = true, slots = {}, showWorkItemTypeIcon = false, + isActive = false, + preventRedirect = false, } = {}) => shallowMount(IssuableItem, { propsData: { @@ -24,6 +26,8 @@ const createComponent = ({ showDiscussions: true, showCheckbox, showWorkItemTypeIcon, + isActive, + preventRedirect, }, slots, stubs: { @@ -43,6 +47,8 @@ describe('IssuableItem', () => { const findTimestampWrapper = () => wrapper.find('[data-testid="issuable-timestamp"]'); const findWorkItemTypeIcon = () => wrapper.findComponent(WorkItemTypeIcon); + const findIssuableTitleLink = () => wrapper.findComponentByTestId('issuable-title-link'); + const findIssuableItemWrapper = () => wrapper.findByTestId('issuable-item-wrapper'); beforeEach(() => { gon.gitlab_url = MOCK_GITLAB_URL; @@ -553,4 +559,35 @@ describe('IssuableItem', () => { }); }); }); + + describe('when preventing redirect on clicking the link', () => { + it('emits an event on item click', () => { + const { iid, webUrl } = mockIssuable; + + wrapper = createComponent({ + preventRedirect: true, + }); + + findIssuableTitleLink().vm.$emit('click', new MouseEvent('click')); + + expect(wrapper.emitted('select-issuable')).toEqual([[{ iid, webUrl }]]); + }); + + it('does not apply highlighted class when item is not active', () => { + wrapper = createComponent({ + preventRedirect: true, + }); + + expect(findIssuableItemWrapper().classes('gl-bg-blue-50')).toBe(false); + }); + + it('applies highlghted class when item is active', () => { + wrapper = createComponent({ + isActive: true, + preventRedirect: true, + }); + + expect(findIssuableItemWrapper().classes('gl-bg-blue-50')).toBe(true); + }); + }); }); diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js index 68904603f40..51aae9b4512 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js @@ -530,4 +530,28 @@ describe('IssuableListRoot', () => { expect(findIssuableGrid().exists()).toBe(true); }); }); + + it('passes `isActive` prop as false if there is no active issuable', () => { + wrapper = createComponent({}); + + expect(findIssuableItem().props('isActive')).toBe(false); + }); + + it('passes `isActive` prop as true if active issuable matches issuable item', () => { + wrapper = createComponent({ + props: { + activeIssuable: mockIssuableListProps.issuables[0], + }, + }); + + expect(findIssuableItem().props('isActive')).toBe(true); + }); + + it('emits `select-issuable` event on emitting `select-issuable` from issuable item', () => { + const mockIssuable = mockIssuableListProps.issuables[0]; + wrapper = createComponent({}); + findIssuableItem().vm.$emit('select-issuable', mockIssuable); + + expect(wrapper.emitted('select-issuable')).toEqual([[mockIssuable]]); + }); }); diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js index d2b7b2e89c8..4d08ad54e58 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js @@ -1,195 +1,289 @@ -import { GlButton, GlBadge, GlIcon, GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import { TYPE_ISSUE, WORKSPACE_PROJECT } from '~/issues/constants'; +import { GlBadge, GlButton, GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { + STATUS_CLOSED, + STATUS_OPEN, + STATUS_REOPENED, + TYPE_ISSUE, + WORKSPACE_PROJECT, +} from '~/issues/constants'; +import { __ } from '~/locale'; import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import IssuableHeader from '~/vue_shared/issuable/show/components/issuable_header.vue'; -import { mockIssuableShowProps, mockIssuable } from '../mock_data'; +import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; +import { mockIssuable, mockIssuableShowProps } from '../mock_data'; -const issuableHeaderProps = { - ...mockIssuable, - ...mockIssuableShowProps, - issuableType: TYPE_ISSUE, - workspaceType: WORKSPACE_PROJECT, -}; - -describe('IssuableHeader', () => { +describe('IssuableHeader component', () => { let wrapper; - const findAvatar = () => wrapper.findByTestId('avatar'); - const findTaskStatusEl = () => wrapper.findByTestId('task-status'); - const findButton = () => wrapper.findComponent(GlButton); - const findGlAvatarLink = () => wrapper.findComponent(GlAvatarLink); + const findConfidentialityBadge = () => wrapper.findComponent(ConfidentialityBadge); + const findStatusBadge = () => wrapper.findComponent(GlBadge); + const findToggleButton = () => wrapper.findComponent(GlButton); + const findAuthorLink = () => wrapper.findComponent(GlLink); + const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip); + const findWorkItemTypeIcon = () => wrapper.findComponent(WorkItemTypeIcon); + const findGlIconWithName = (name) => + wrapper.findAllComponents(GlIcon).filter((component) => component.props('name') === name); + const findIcon = (name) => + findGlIconWithName(name).exists() ? findGlIconWithName(name).at(0) : undefined; + const findBlockedIcon = () => findIcon('lock'); + const findHiddenIcon = () => findIcon('spam'); + const findExternalLinkIcon = () => findIcon('external-link'); + const findFirstContributionIcon = () => findIcon('first-contribution'); + const findComponentTooltip = (component) => getBinding(component.element, 'gl-tooltip'); const createComponent = (props = {}, { stubs } = {}) => { - wrapper = shallowMountExtended(IssuableHeader, { + wrapper = shallowMount(IssuableHeader, { + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, propsData: { - ...issuableHeaderProps, + ...mockIssuable, + ...mockIssuableShowProps, + issuableState: STATUS_OPEN, + issuableType: TYPE_ISSUE, + workspaceType: WORKSPACE_PROJECT, ...props, }, slots: { - 'status-badge': 'Open', - 'header-actions': ` - <button class="js-close">Close issuable</button> - <a class="js-new" href="/gitlab-org/gitlab-shell/-/issues/new">New issuable</a> - `, + 'header-actions': `Header actions slot`, + }, + stubs: { + GlSprintf, + ...stubs, }, - stubs, }); }; - afterEach(() => { - resetHTMLFixture(); - }); + describe('status badge', () => { + describe('variant', () => { + it('is `success` when status is open', () => { + createComponent({ issuableState: STATUS_OPEN }); - describe('computed', () => { - describe('authorId', () => { - it('returns numeric ID from GraphQL ID of `author` prop', () => { - createComponent(); - expect(findGlAvatarLink().attributes('data-user-id')).toBe('1'); + expect(findStatusBadge().props('variant')).toBe('success'); + }); + + it('is `success` when status is reopened', () => { + createComponent({ issuableState: STATUS_REOPENED }); + + expect(findStatusBadge().props('variant')).toBe('success'); + }); + + it('is `info` when status is closed', () => { + createComponent({ issuableState: STATUS_CLOSED }); + + expect(findStatusBadge().props('variant')).toBe('info'); }); }); - }); - describe('handleRightSidebarToggleClick', () => { - beforeEach(() => { - setHTMLFixture('<button class="js-toggle-right-sidebar-button">Collapse sidebar</button>'); + describe('icon', () => { + it('renders when statusIcon prop exists', () => { + createComponent({ statusIcon: 'issues' }); + + expect(findStatusBadge().findComponent(GlIcon).props('name')).toBe('issues'); + }); + + it('does not render when statusIcon prop does not exist', () => { + createComponent({ statusIcon: '' }); + + expect(findStatusBadge().findComponent(GlIcon).exists()).toBe(false); + }); }); - it('emits a "toggle" event', () => { + it('renders status text', () => { createComponent(); - findButton().vm.$emit('click'); + expect(findStatusBadge().text()).toBe(__('Open')); + }); + }); + + describe('confidential badge', () => { + it('renders when issuable is confidential', () => { + createComponent({ confidential: true }); + + expect(findConfidentialityBadge().props()).toEqual({ + issuableType: 'issue', + workspaceType: 'project', + }); + }); + + it('does not render when issuable is not confidential', () => { + createComponent({ confidential: false }); - expect(wrapper.emitted('toggle')).toEqual([[]]); + expect(findConfidentialityBadge().exists()).toBe(false); }); + }); - it('dispatches `click` event on sidebar toggle button', () => { - createComponent(); - const toggleSidebarButtonEl = document.querySelector('.js-toggle-right-sidebar-button'); - const dispatchEvent = jest - .spyOn(toggleSidebarButtonEl, 'dispatchEvent') - .mockImplementation(jest.fn); + describe('blocked icon', () => { + it('renders when issuable is blocked', () => { + createComponent({ blocked: true }); - findButton().vm.$emit('click'); + expect(findBlockedIcon().props('ariaLabel')).toBe('Blocked'); + }); - expect(dispatchEvent).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'click', - }), + it('has tooltip', () => { + createComponent({ blocked: true }); + + expect(findComponentTooltip(findBlockedIcon())).toBeDefined(); + expect(findBlockedIcon().attributes('title')).toBe( + 'This issue is locked. Only project members can comment.', ); }); + + it('does not render when issuable is not blocked', () => { + createComponent({ blocked: false }); + + expect(findBlockedIcon()).toBeUndefined(); + }); }); - describe('template', () => { - it('renders issuable status icon and text', () => { - createComponent(); - const statusBoxEl = wrapper.findComponent(GlBadge); - const statusIconEl = statusBoxEl.findComponent(GlIcon); + describe('hidden icon', () => { + it('renders when issuable is hidden', () => { + createComponent({ isHidden: true }); - expect(statusBoxEl.exists()).toBe(true); - expect(statusIconEl.props('name')).toBe(mockIssuableShowProps.statusIcon); - expect(statusIconEl.attributes('class')).toBe(mockIssuableShowProps.statusIconClass); - expect(statusBoxEl.text()).toContain('Open'); + expect(findHiddenIcon().props('ariaLabel')).toBe('Hidden'); }); - it('renders blocked icon when issuable is blocked', () => { - createComponent({ - blocked: true, - }); + it('has tooltip', () => { + createComponent({ isHidden: true }); - const blockedEl = wrapper.findByTestId('blocked'); + expect(findComponentTooltip(findHiddenIcon())).toBeDefined(); + expect(findHiddenIcon().attributes('title')).toBe( + 'This issue is hidden because its author has been banned', + ); + }); - expect(blockedEl.exists()).toBe(true); - expect(blockedEl.findComponent(GlIcon).props('name')).toBe('lock'); + it('does not render when issuable is not hidden', () => { + createComponent({ isHidden: false }); + + expect(findHiddenIcon()).toBeUndefined(); }); + }); - it('renders confidential icon when issuable is confidential', () => { - createComponent({ confidential: true }); + describe('work item type icon', () => { + it('renders when showWorkItemTypeIcon=true and work item type exists', () => { + createComponent({ showWorkItemTypeIcon: true, issuableType: 'issue' }); - expect(wrapper.findComponent(ConfidentialityBadge).props()).toEqual({ - issuableType: 'issue', - workspaceType: 'project', + expect(findWorkItemTypeIcon().props()).toMatchObject({ + showText: true, + workItemType: 'ISSUE', }); }); - it('renders issuable author avatar', () => { + it('does not render when showWorkItemTypeIcon=false', () => { + createComponent({ showWorkItemTypeIcon: false }); + + expect(findWorkItemTypeIcon().exists()).toBe(false); + }); + }); + + describe('timeago tooltip', () => { + it('renders', () => { createComponent(); - const { username, name, webUrl, avatarUrl } = mockIssuable.author; - const avatarElAttrs = { + + expect(findTimeAgoTooltip().props('time')).toBe('2020-06-29T13:52:56Z'); + }); + }); + + describe('author', () => { + it('renders link', () => { + createComponent(); + + expect(findAuthorLink().text()).toContain('Administrator'); + expect(findAuthorLink().attributes()).toMatchObject({ + href: 'http://0.0.0.0:3000/root', 'data-user-id': '1', - 'data-username': username, - 'data-name': name, - href: webUrl, - target: '_blank', - }; - const avatarEl = findAvatar(); - expect(avatarEl.exists()).toBe(true); - expect(avatarEl.attributes()).toMatchObject(avatarElAttrs); - expect(avatarEl.findComponent(GlAvatarLabeled).attributes()).toMatchObject({ - size: '24', - src: avatarUrl, - label: name, }); - expect(avatarEl.findComponent(GlAvatarLabeled).findComponent(GlIcon).exists()).toBe(false); + expect(findAuthorLink().classes()).toContain('js-user-link'); + }); + + describe('when author exists outside of GitLab', () => { + it('renders external link icon', () => { + createComponent({ author: { webUrl: 'https://example.com/test-user' } }); + + expect(findExternalLinkIcon().props('ariaLabel')).toBe('external link'); + }); + }); + }); + + describe('first contribution icon', () => { + it('renders when isFirstContribution=true', () => { + createComponent({ isFirstContribution: true }); + + expect(findFirstContributionIcon().props('ariaLabel')).toBe('1st contribution!'); + }); + + it('has tooltip', () => { + createComponent({ isFirstContribution: true }); + + expect(findComponentTooltip(findFirstContributionIcon())).toBeDefined(); + expect(findFirstContributionIcon().attributes('title')).toBe('1st contribution!'); }); + it('does not render when isFirstContribution=false', () => { + createComponent({ isFirstContribution: false }); + + expect(findFirstContributionIcon()).toBeUndefined(); + }); + }); + + describe('task status', () => { it('renders task status text when `taskCompletionStatus` prop is defined', () => { createComponent(); - expect(findTaskStatusEl().exists()).toBe(true); - expect(findTaskStatusEl().text()).toContain('0 of 5 checklist items completed'); + expect(wrapper.text()).toContain('0 of 5 checklist items completed'); }); it('does not render task status text when tasks count is 0', () => { - createComponent({ - taskCompletionStatus: { - count: 0, - completedCount: 0, - }, - }); + createComponent({ taskCompletionStatus: { count: 0, completedCount: 0 } }); - expect(findTaskStatusEl().exists()).toBe(false); + expect(wrapper.text()).not.toContain('checklist item'); }); + }); - it('renders sidebar toggle button', () => { + describe('sidebar toggle button', () => { + beforeEach(() => { + setHTMLFixture('<button class="js-toggle-right-sidebar-button">Collapse sidebar</button>'); createComponent(); - const toggleButtonEl = wrapper.findByTestId('sidebar-toggle'); - - expect(toggleButtonEl.exists()).toBe(true); - expect(toggleButtonEl.props('icon')).toBe('chevron-double-lg-left'); }); - it('renders header actions', () => { - createComponent(); - const actionsEl = wrapper.findByTestId('header-actions'); + afterEach(() => { + resetHTMLFixture(); + }); - expect(actionsEl.find('button.js-close').exists()).toBe(true); - expect(actionsEl.find('a.js-new').exists()).toBe(true); + it('renders', () => { + expect(findToggleButton().props('icon')).toBe('chevron-double-lg-left'); + expect(findToggleButton().attributes('aria-label')).toBe('Expand sidebar'); }); - describe('when author exists outside of GitLab', () => { - it("renders 'external-link' icon in avatar label", () => { - createComponent( - { - author: { - ...issuableHeaderProps.author, - webUrl: 'https://jira.com/test-user/author.jpg', - }, - }, - { - stubs: { - GlAvatarLabeled, - }, - }, - ); - - const avatarEl = wrapper.findComponent(GlAvatarLabeled); - const icon = avatarEl.findComponent(GlIcon); - - expect(icon.exists()).toBe(true); - expect(icon.props('name')).toBe('external-link'); + describe('when clicked', () => { + it('emits a "toggle" event', () => { + findToggleButton().vm.$emit('click'); + + expect(wrapper.emitted('toggle')).toEqual([[]]); + }); + + it('dispatches `click` event on sidebar toggle button', () => { + const toggleSidebarButton = document.querySelector('.js-toggle-right-sidebar-button'); + const dispatchEvent = jest + .spyOn(toggleSidebarButton, 'dispatchEvent') + .mockImplementation(jest.fn); + + findToggleButton().vm.$emit('click'); + + expect(dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({ type: 'click' })); }); }); }); + + describe('header actions', () => { + it('renders slot', () => { + createComponent(); + + expect(wrapper.text()).toContain('Header actions slot'); + }); + }); }); diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js index f976e0499f0..ad7afefff12 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js @@ -1,3 +1,4 @@ +import { GlBadge } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import IssuableBody from '~/vue_shared/issuable/show/components/issuable_body.vue'; @@ -72,7 +73,7 @@ describe('IssuableShowRoot', () => { author, taskCompletionStatus, }); - expect(issuableHeader.find('.issuable-status-badge').text()).toContain('Open'); + expect(issuableHeader.findComponent(GlBadge).text()).toBe('Open'); expect(issuableHeader.find('.detail-page-header-actions button.js-close').exists()).toBe( true, ); |