diff options
Diffstat (limited to 'spec/frontend/vue_shared/components')
50 files changed, 1524 insertions, 2635 deletions
diff --git a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap index 04ae2a0f34d..20ea897e29c 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap @@ -5,12 +5,17 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` class="awards js-awards-block" > <button - class="btn award-control" + class="btn gl-mr-3 btn-default btn-md gl-button" data-testid="award-button" title="Ada, Leonardo, and Marie" type="button" > + <!----> + + <!----> + <span + class="award-emoji-block" data-testid="award-html" > @@ -23,18 +28,28 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> <span - class="award-control-text js-counter" + class="gl-button-text" > - 3 + + <span + class="js-counter" + > + 3 + </span> </span> </button> <button - class="btn award-control active" + class="btn gl-mr-3 btn-default btn-md gl-button selected" data-testid="award-button" title="You, Ada, and Marie" type="button" > + <!----> + + <!----> + <span + class="award-emoji-block" data-testid="award-html" > @@ -47,18 +62,28 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> <span - class="award-control-text js-counter" + class="gl-button-text" > - 3 + + <span + class="js-counter" + > + 3 + </span> </span> </button> <button - class="btn award-control" + class="btn gl-mr-3 btn-default btn-md gl-button" data-testid="award-button" title="Ada and Jane" type="button" > + <!----> + + <!----> + <span + class="award-emoji-block" data-testid="award-html" > @@ -71,18 +96,28 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> <span - class="award-control-text js-counter" + class="gl-button-text" > - 2 + + <span + class="js-counter" + > + 2 + </span> </span> </button> <button - class="btn award-control active" + class="btn gl-mr-3 btn-default btn-md gl-button selected" data-testid="award-button" title="You, Ada, Jane, and Leonardo" type="button" > + <!----> + + <!----> + <span + class="award-emoji-block" data-testid="award-html" > @@ -95,18 +130,28 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> <span - class="award-control-text js-counter" + class="gl-button-text" > - 4 + + <span + class="js-counter" + > + 4 + </span> </span> </button> <button - class="btn award-control active" + class="btn gl-mr-3 btn-default btn-md gl-button selected" data-testid="award-button" title="You" type="button" > + <!----> + + <!----> + <span + class="award-emoji-block" data-testid="award-html" > @@ -119,18 +164,28 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> <span - class="award-control-text js-counter" + class="gl-button-text" > - 1 + + <span + class="js-counter" + > + 1 + </span> </span> </button> <button - class="btn award-control" + class="btn gl-mr-3 btn-default btn-md gl-button" data-testid="award-button" title="Marie" type="button" > + <!----> + + <!----> + <span + class="award-emoji-block" data-testid="award-html" > @@ -143,18 +198,28 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> <span - class="award-control-text js-counter" + class="gl-button-text" > - 1 + + <span + class="js-counter" + > + 1 + </span> </span> </button> <button - class="btn award-control active" + class="btn gl-mr-3 btn-default btn-md gl-button selected" data-testid="award-button" title="You" type="button" > + <!----> + + <!----> + <span + class="award-emoji-block" data-testid="award-html" > @@ -167,9 +232,14 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> <span - class="award-control-text js-counter" + class="gl-button-text" > - 1 + + <span + class="js-counter" + > + 1 + </span> </span> </button> @@ -178,46 +248,59 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` > <button aria-label="Add reaction" - class="award-control btn js-add-award js-test-add-button-class" + class="btn add-reaction-button js-add-award btn-default btn-md gl-button js-test-add-button-class" title="Add reaction" type="button" > - <span - class="award-control-icon award-control-icon-neutral" - > - <gl-icon-stub - aria-hidden="true" - name="slight-smile" - size="16" - /> - </span> + <!----> + <!----> + <span - class="award-control-icon award-control-icon-positive" + class="gl-button-text" > - <gl-icon-stub - aria-hidden="true" - name="smiley" - size="16" - /> + <span + class="reaction-control-icon reaction-control-icon-neutral" + > + <svg + aria-hidden="true" + class="gl-icon s16" + data-testid="slight-smile-icon" + > + <use + href="#slight-smile" + /> + </svg> + </span> + + <span + class="reaction-control-icon reaction-control-icon-positive" + > + <svg + aria-hidden="true" + class="gl-icon s16" + data-testid="smiley-icon" + > + <use + href="#smiley" + /> + </svg> + </span> + + <span + class="reaction-control-icon reaction-control-icon-super-positive" + > + <svg + aria-hidden="true" + class="gl-icon s16" + data-testid="smile-icon" + > + <use + href="#smile" + /> + </svg> + </span> </span> - - <span - class="award-control-icon award-control-icon-super-positive" - > - <gl-icon-stub - aria-hidden="true" - name="smiley" - size="16" - /> - </span> - - <gl-loading-icon-stub - class="award-control-icon-loading" - color="dark" - label="Loading" - size="md" - /> </button> </div> </div> diff --git a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap index ec4a81054db..63d38e7587a 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap @@ -4,7 +4,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` <gl-dropdown-stub category="primary" headertext="" - right="" + right="true" size="medium" text="Clone" variant="info" diff --git a/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap index 19a649089e0..adb6c935f96 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap @@ -11,6 +11,7 @@ exports[`Expand button on click when short text is provided renders button after <!----> <svg + aria-hidden="true" class="gl-button-icon gl-icon s16" data-testid="ellipsis_h-icon" > @@ -39,6 +40,7 @@ exports[`Expand button on click when short text is provided renders button after <!----> <svg + aria-hidden="true" class="gl-button-icon gl-icon s16" data-testid="ellipsis_h-icon" > @@ -62,6 +64,7 @@ exports[`Expand button when short text is provided renders button before text 1` <!----> <svg + aria-hidden="true" class="gl-button-icon gl-icon s16" data-testid="ellipsis_h-icon" > @@ -90,6 +93,7 @@ exports[`Expand button when short text is provided renders button before text 1` <!----> <svg + aria-hidden="true" class="gl-button-icon gl-icon s16" data-testid="ellipsis_h-icon" > 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 8eb0e8f9550..dd88ba9a6fb 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 @@ -2,7 +2,7 @@ exports[`SplitButton renders actionItems 1`] = ` <gl-dropdown-stub - category="tertiary" + category="primary" headertext="" menu-class="" size="medium" @@ -14,6 +14,7 @@ exports[`SplitButton renders actionItems 1`] = ` avatarurl="" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischecked="true" ischeckitem="true" @@ -33,6 +34,7 @@ exports[`SplitButton renders actionItems 1`] = ` avatarurl="" iconcolor="" iconname="" + iconrightarialabel="" iconrightname="" ischeckitem="true" secondarytext="" diff --git a/spec/frontend/vue_shared/components/awards_list_spec.js b/spec/frontend/vue_shared/components/awards_list_spec.js index 63fc8a5749d..d20de81c446 100644 --- a/spec/frontend/vue_shared/components/awards_list_spec.js +++ b/spec/frontend/vue_shared/components/awards_list_spec.js @@ -1,4 +1,4 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import AwardsList from '~/vue_shared/components/awards_list.vue'; const createUser = (id, name) => ({ id, name }); @@ -41,6 +41,8 @@ const TEST_AWARDS = [ ]; const TEST_ADD_BUTTON_CLASS = 'js-test-add-button-class'; +const REACTION_CONTROL_CLASSES = ['btn', 'gl-mr-3', 'btn-default', 'btn-md', 'gl-button']; + describe('vue_shared/components/awards_list', () => { let wrapper; @@ -54,16 +56,16 @@ describe('vue_shared/components/awards_list', () => { throw new Error('There should only be one wrapper created per test'); } - wrapper = shallowMount(AwardsList, { propsData: props }); + wrapper = mount(AwardsList, { propsData: props }); }; const matchingEmojiTag = name => expect.stringMatching(`gl-emoji data-name="${name}"`); - const findAwardButtons = () => wrapper.findAll('[data-testid="award-button"'); + const findAwardButtons = () => wrapper.findAll('[data-testid="award-button"]'); const findAwardsData = () => findAwardButtons().wrappers.map(x => { return { classes: x.classes(), title: x.attributes('title'), - html: x.find('[data-testid="award-html"]').element.innerHTML, + html: x.find('[data-testid="award-html"]').html(), count: Number(x.find('.js-counter').text()), }; }); @@ -86,43 +88,43 @@ describe('vue_shared/components/awards_list', () => { it('shows awards in correct order', () => { expect(findAwardsData()).toEqual([ { - classes: ['btn', 'award-control'], + classes: REACTION_CONTROL_CLASSES, count: 3, html: matchingEmojiTag(EMOJI_THUMBSUP), title: 'Ada, Leonardo, and Marie', }, { - classes: ['btn', 'award-control', 'active'], + classes: [...REACTION_CONTROL_CLASSES, 'selected'], count: 3, html: matchingEmojiTag(EMOJI_THUMBSDOWN), title: 'You, Ada, and Marie', }, { - classes: ['btn', 'award-control'], + classes: REACTION_CONTROL_CLASSES, count: 2, html: matchingEmojiTag(EMOJI_SMILE), title: 'Ada and Jane', }, { - classes: ['btn', 'award-control', 'active'], + classes: [...REACTION_CONTROL_CLASSES, 'selected'], count: 4, html: matchingEmojiTag(EMOJI_OK), title: 'You, Ada, Jane, and Leonardo', }, { - classes: ['btn', 'award-control', 'active'], + classes: [...REACTION_CONTROL_CLASSES, 'selected'], count: 1, html: matchingEmojiTag(EMOJI_CACTUS), title: 'You', }, { - classes: ['btn', 'award-control'], + classes: REACTION_CONTROL_CLASSES, count: 1, html: matchingEmojiTag(EMOJI_A), title: 'Marie', }, { - classes: ['btn', 'award-control', 'active'], + classes: [...REACTION_CONTROL_CLASSES, 'selected'], count: 1, html: matchingEmojiTag(EMOJI_B), title: 'You', @@ -135,7 +137,7 @@ describe('vue_shared/components/awards_list', () => { findAwardButtons() .at(2) - .trigger('click'); + .vm.$emit('click'); expect(wrapper.emitted().award).toEqual([[EMOJI_SMILE]]); }); @@ -162,7 +164,7 @@ describe('vue_shared/components/awards_list', () => { findAwardButtons() .at(0) - .trigger('click'); + .vm.$emit('click'); expect(wrapper.emitted().award).toEqual([[Number(EMOJI_100)]]); }); @@ -225,26 +227,26 @@ describe('vue_shared/components/awards_list', () => { it('shows awards in correct order', () => { expect(findAwardsData()).toEqual([ { - classes: ['btn', 'award-control'], + classes: REACTION_CONTROL_CLASSES, count: 0, html: matchingEmojiTag(EMOJI_THUMBSUP), title: '', }, { - classes: ['btn', 'award-control'], + classes: REACTION_CONTROL_CLASSES, count: 0, html: matchingEmojiTag(EMOJI_THUMBSDOWN), title: '', }, // We expect the EMOJI_100 before the EMOJI_SMILE because it was given as a defaultAward { - classes: ['btn', 'award-control'], + classes: REACTION_CONTROL_CLASSES, count: 1, html: matchingEmojiTag(EMOJI_100), title: 'Marie', }, { - classes: ['btn', 'award-control'], + classes: REACTION_CONTROL_CLASSES, count: 1, html: matchingEmojiTag(EMOJI_SMILE), title: 'Marie', diff --git a/spec/frontend/vue_shared/components/callout_spec.js b/spec/frontend/vue_shared/components/callout_spec.js deleted file mode 100644 index 7c9bb6b4650..00000000000 --- a/spec/frontend/vue_shared/components/callout_spec.js +++ /dev/null @@ -1,63 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Callout from '~/vue_shared/components/callout.vue'; - -const TEST_MESSAGE = 'This is a callout message!'; -const TEST_SLOT = '<button>This is a callout slot!</button>'; - -describe('Callout Component', () => { - let wrapper; - - const factory = options => { - wrapper = shallowMount(Callout, { - ...options, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it('should render the appropriate variant of callout', () => { - factory({ - propsData: { - category: 'info', - message: TEST_MESSAGE, - }, - }); - - expect(wrapper.classes()).toEqual(['bs-callout', 'bs-callout-info']); - - expect(wrapper.element.tagName).toEqual('DIV'); - }); - - it('should render accessibility attributes', () => { - factory({ - propsData: { - message: TEST_MESSAGE, - }, - }); - - expect(wrapper.attributes('role')).toEqual('alert'); - expect(wrapper.attributes('aria-live')).toEqual('assertive'); - }); - - it('should render the provided message', () => { - factory({ - propsData: { - message: TEST_MESSAGE, - }, - }); - - expect(wrapper.element.innerHTML.trim()).toEqual(TEST_MESSAGE); - }); - - it('should render the provided slot', () => { - factory({ - slots: { - default: TEST_SLOT, - }, - }); - - expect(wrapper.element.innerHTML.trim()).toEqual(TEST_SLOT); - }); -}); diff --git a/spec/frontend/vue_shared/components/clipboard_button_spec.js b/spec/frontend/vue_shared/components/clipboard_button_spec.js index 51a2653befc..ac0be1537b7 100644 --- a/spec/frontend/vue_shared/components/clipboard_button_spec.js +++ b/spec/frontend/vue_shared/components/clipboard_button_spec.js @@ -1,16 +1,19 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import { GlButton } from '@gitlab/ui'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; describe('clipboard button', () => { let wrapper; - const createWrapper = propsData => { - wrapper = shallowMount(ClipboardButton, { + const createWrapper = (propsData, options = {}) => { + wrapper = mount(ClipboardButton, { propsData, + ...options, }); }; + const findButton = () => wrapper.find(GlButton); + afterEach(() => { wrapper.destroy(); wrapper = null; @@ -26,7 +29,7 @@ describe('clipboard button', () => { }); it('renders a button for clipboard', () => { - expect(wrapper.find(GlButton).exists()).toBe(true); + expect(findButton().exists()).toBe(true); expect(wrapper.attributes('data-clipboard-text')).toBe('copy me'); }); @@ -53,4 +56,35 @@ describe('clipboard button', () => { ); }); }); + + it('renders default slot as button text', () => { + createWrapper( + { + text: 'copy me', + title: 'Copy this value', + }, + { + slots: { + default: 'Foo bar', + }, + }, + ); + + expect(findButton().text()).toBe('Foo bar'); + }); + + it('re-emits button events', () => { + const onClick = jest.fn(); + createWrapper( + { + text: 'copy me', + title: 'Copy this value', + }, + { listeners: { click: onClick } }, + ); + + findButton().trigger('click'); + + expect(onClick).toHaveBeenCalled(); + }); }); diff --git a/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js b/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js new file mode 100644 index 00000000000..a50a4b742b3 --- /dev/null +++ b/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js @@ -0,0 +1,140 @@ +import { GlFormGroup, GlFormInput, GlFormInputGroup, GlLink } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; + +import ColorPicker from '~/vue_shared/components/color_picker/color_picker.vue'; + +describe('ColorPicker', () => { + let wrapper; + + const createComponent = (fn = mount, propsData = {}) => { + wrapper = fn(ColorPicker, { + propsData, + }); + }; + + const setColor = '#000000'; + const label = () => wrapper.find(GlFormGroup).attributes('label'); + const colorPreview = () => wrapper.find('[data-testid="color-preview"]'); + const colorPicker = () => wrapper.find(GlFormInput); + const colorInput = () => wrapper.find(GlFormInputGroup).find('input[type="text"]'); + const invalidFeedback = () => wrapper.find('.invalid-feedback'); + const description = () => wrapper.find(GlFormGroup).attributes('description'); + const presetColors = () => wrapper.findAll(GlLink); + + beforeEach(() => { + gon.suggested_label_colors = { + [setColor]: 'Black', + '#0033CC': 'UA blue', + '#428BCA': 'Moderate blue', + '#44AD8E': 'Lime green', + }; + + createComponent(shallowMount); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('label', () => { + it('hides the label if the label is not passed', () => { + expect(label()).toBe(''); + }); + + it('shows the label if the label is passed', () => { + createComponent(shallowMount, { label: 'test' }); + + expect(label()).toBe('test'); + }); + }); + + describe('behavior', () => { + it('by default has no values', () => { + createComponent(); + + expect(colorPreview().attributes('style')).toBe(undefined); + expect(colorPicker().attributes('value')).toBe(undefined); + expect(colorInput().props('value')).toBe(''); + }); + + it('has a color set on initialization', () => { + createComponent(shallowMount, { setColor }); + + expect(wrapper.vm.$data.selectedColor).toBe(setColor); + }); + + it('emits input event from component when a color is selected', async () => { + createComponent(); + await colorInput().setValue(setColor); + + expect(wrapper.emitted().input[0]).toEqual([setColor]); + }); + + it('trims spaces from submitted colors', async () => { + createComponent(); + await colorInput().setValue(` ${setColor} `); + + expect(wrapper.vm.$data.selectedColor).toBe(setColor); + }); + + it('shows invalid feedback when an invalid color is used', async () => { + createComponent(); + await colorInput().setValue('abcd'); + + expect(invalidFeedback().text()).toBe( + 'Please enter a valid hex (#RRGGBB or #RGB) color value', + ); + expect(wrapper.emitted().input).toBe(undefined); + }); + + it('shows an invalid feedback border on the preview when an invalid color is used', async () => { + createComponent(); + await colorInput().setValue('abcd'); + + expect(colorPreview().attributes('class')).toContain('gl-inset-border-1-red-500'); + }); + }); + + describe('inputs', () => { + it('has color input value entered', async () => { + createComponent(); + await colorInput().setValue(setColor); + + expect(wrapper.vm.$data.selectedColor).toBe(setColor); + }); + + it('has color picker value entered', async () => { + createComponent(); + await colorPicker().setValue(setColor); + + expect(wrapper.vm.$data.selectedColor).toBe(setColor); + }); + }); + + describe('preset colors', () => { + it('hides the suggested colors if they are empty', () => { + gon.suggested_label_colors = {}; + createComponent(shallowMount); + + expect(description()).toBe('Choose any color'); + expect(presetColors().exists()).toBe(false); + }); + + it('shows the suggested colors', () => { + createComponent(shallowMount); + expect(description()).toBe( + 'Choose any color. Or you can choose one of the suggested colors below', + ); + expect(presetColors()).toHaveLength(4); + }); + + it('has preset color selected', async () => { + createComponent(); + await presetColors() + .at(0) + .trigger('click'); + + expect(wrapper.vm.$data.selectedColor).toBe(setColor); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js index 64bfff3dfa1..8cc5d6775a7 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js @@ -17,11 +17,14 @@ import RecentSearchesService from '~/filtered_search/services/recent_searches_se import { mockAvailableTokens, + mockMembershipToken, + mockMembershipTokenOptionsWithoutTitles, mockSortOptions, mockHistoryItems, tokenValueAuthor, tokenValueLabel, tokenValueMilestone, + tokenValueMembership, } from './mock_data'; jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({ @@ -412,6 +415,42 @@ describe('FilteredSearchBarRoot', () => { wrapperFullMount.destroy(); }); + describe('when token options have `title` attribute defined', () => { + it('renders search history items using the provided `title` attribute', async () => { + const wrapperFullMount = createComponent({ + sortOptions: mockSortOptions, + tokens: [mockMembershipToken], + shallow: false, + }); + + wrapperFullMount.vm.recentSearchesStore.addRecentSearch([tokenValueMembership]); + + await wrapperFullMount.vm.$nextTick(); + + expect(wrapperFullMount.find(GlDropdownItem).text()).toBe('Membership := Direct'); + + wrapperFullMount.destroy(); + }); + }); + + describe('when token options have do not have `title` attribute defined', () => { + it('renders search history items using the provided `value` attribute', async () => { + const wrapperFullMount = createComponent({ + sortOptions: mockSortOptions, + tokens: [mockMembershipTokenOptionsWithoutTitles], + shallow: false, + }); + + wrapperFullMount.vm.recentSearchesStore.addRecentSearch([tokenValueMembership]); + + await wrapperFullMount.vm.$nextTick(); + + expect(wrapperFullMount.find(GlDropdownItem).text()).toBe('Membership := exclude'); + + wrapperFullMount.destroy(); + }); + }); + it('renders sort dropdown component', () => { expect(wrapper.find(GlButtonGroup).exists()).toBe(true); expect(wrapper.find(GlDropdown).exists()).toBe(true); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js index e0a3208cac9..64fbe70696d 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -1,3 +1,4 @@ +import { GlFilteredSearchToken } from '@gitlab/ui'; import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; import Api from '~/api'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; @@ -102,6 +103,21 @@ export const mockMilestoneToken = { fetchMilestones: () => Promise.resolve({ data: mockMilestones }), }; +export const mockMembershipToken = { + type: 'with_inherited_permissions', + icon: 'group', + title: 'Membership', + token: GlFilteredSearchToken, + unique: true, + operators: [{ value: '=', description: 'is' }], + options: [{ value: 'exclude', title: 'Direct' }, { value: 'only', title: 'Inherited' }], +}; + +export const mockMembershipTokenOptionsWithoutTitles = { + ...mockMembershipToken, + options: [{ value: 'exclude' }, { value: 'only' }], +}; + export const mockAvailableTokens = [mockAuthorToken, mockLabelToken, mockMilestoneToken]; export const tokenValueAuthor = { @@ -128,6 +144,14 @@ export const tokenValueMilestone = { }, }; +export const tokenValueMembership = { + type: 'with_inherited_permissions', + value: { + operator: '=', + data: 'exclude', + }, +}; + export const tokenValuePlain = { type: 'filtered-search-term', value: { data: 'foo' }, diff --git a/spec/frontend/vue_shared/components/gfm_autocomplete/__snapshots__/utils_spec.js.snap b/spec/frontend/vue_shared/components/gfm_autocomplete/__snapshots__/utils_spec.js.snap new file mode 100644 index 00000000000..d0fa2086fdc --- /dev/null +++ b/spec/frontend/vue_shared/components/gfm_autocomplete/__snapshots__/utils_spec.js.snap @@ -0,0 +1,47 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`gfm_autocomplete/utils issues config shows the iid and title in the menu item within a project context 1`] = `"<small>123456</small> Project context issue title <script>alert('hi')</script>"`; + +exports[`gfm_autocomplete/utils issues config shows the reference and title in the menu item within a group context 1`] = `"<small>gitlab#987654</small> Group context issue title <script>alert('hi')</script>"`; + +exports[`gfm_autocomplete/utils labels config shows the title in the menu item 1`] = ` +" + <span class=\\"dropdown-label-box\\" style=\\"background: #123456;\\"></span> + bug <script>alert('hi')</script>" +`; + +exports[`gfm_autocomplete/utils members config shows an avatar character, name, parent name, and count in the menu item for a group 1`] = ` +" + <div class=\\"gl-display-flex gl-align-items-center\\"> + <div class=\\"gl-avatar gl-avatar-s24 gl-flex-shrink-0 gl-rounded-small + gl-display-flex gl-align-items-center gl-justify-content-center\\" aria-hidden=\\"true\\"> + G</div> + <div class=\\"gl-font-sm gl-line-height-normal gl-ml-3\\"> + <div>1-1s <script>alert('hi')</script> (2)</div> + <div class=\\"gl-text-gray-700\\">GitLab Support Team</div> + </div> + + </div> + " +`; + +exports[`gfm_autocomplete/utils members config shows the avatar, name and username in the menu item for a user 1`] = ` +" + <div class=\\"gl-display-flex gl-align-items-center\\"> + <img class=\\"gl-avatar gl-avatar-s24 gl-flex-shrink-0 gl-avatar-circle\\" src=\\"/uploads/-/system/user/avatar/123456/avatar.png\\" alt=\\"\\" /> + <div class=\\"gl-font-sm gl-line-height-normal gl-ml-3\\"> + <div>My Name <script>alert('hi')</script></div> + <div class=\\"gl-text-gray-700\\">@myusername</div> + </div> + + </div> + " +`; + +exports[`gfm_autocomplete/utils merge requests config shows the iid and title in the menu item within a project context 1`] = `"<small>123456</small> Project context merge request title <script>alert('hi')</script>"`; + +exports[`gfm_autocomplete/utils merge requests config shows the reference and title in the menu item within a group context 1`] = `"<small>gitlab!456789</small> Group context merge request title <script>alert('hi')</script>"`; + +exports[`gfm_autocomplete/utils milestones config shows the title in the menu item 1`] = `"13.2 <script>alert('hi')</script>"`; + +exports[`gfm_autocomplete/utils snippets config shows the id and title in the menu item 1`] = `"<small>123456</small> Snippet title <script>alert('hi')</script>"`; diff --git a/spec/frontend/vue_shared/components/gl_mentions_spec.js b/spec/frontend/vue_shared/components/gfm_autocomplete/gfm_autocomplete_spec.js index 32fc055a77d..b4002fdf4ec 100644 --- a/spec/frontend/vue_shared/components/gl_mentions_spec.js +++ b/spec/frontend/vue_shared/components/gfm_autocomplete/gfm_autocomplete_spec.js @@ -1,15 +1,15 @@ +import Tribute from '@gitlab/tributejs'; import { shallowMount } from '@vue/test-utils'; -import Tribute from 'tributejs'; -import GlMentions from '~/vue_shared/components/gl_mentions.vue'; +import GfmAutocomplete from '~/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue'; -describe('GlMentions', () => { +describe('GfmAutocomplete', () => { let wrapper; - describe('Tribute', () => { + describe('tribute', () => { const mentions = '/gitlab-org/gitlab-test/-/autocomplete_sources/members?type=Issue&type_id=1'; beforeEach(() => { - wrapper = shallowMount(GlMentions, { + wrapper = shallowMount(GfmAutocomplete, { propsData: { dataSources: { mentions, diff --git a/spec/frontend/vue_shared/components/gfm_autocomplete/utils_spec.js b/spec/frontend/vue_shared/components/gfm_autocomplete/utils_spec.js new file mode 100644 index 00000000000..647f8c6e000 --- /dev/null +++ b/spec/frontend/vue_shared/components/gfm_autocomplete/utils_spec.js @@ -0,0 +1,344 @@ +import { escape, last } from 'lodash'; +import { GfmAutocompleteType, tributeConfig } from '~/vue_shared/components/gfm_autocomplete/utils'; + +describe('gfm_autocomplete/utils', () => { + describe('issues config', () => { + const issuesConfig = tributeConfig[GfmAutocompleteType.Issues].config; + const groupContextIssue = { + iid: 987654, + reference: 'gitlab#987654', + title: "Group context issue title <script>alert('hi')</script>", + }; + const projectContextIssue = { + id: null, + iid: 123456, + time_estimate: 0, + title: "Project context issue title <script>alert('hi')</script>", + }; + + it('uses # as the trigger', () => { + expect(issuesConfig.trigger).toBe('#'); + }); + + it('searches using both the iid and title', () => { + expect(issuesConfig.lookup(projectContextIssue)).toBe( + `${projectContextIssue.iid}${projectContextIssue.title}`, + ); + }); + + it('shows the reference and title in the menu item within a group context', () => { + expect(issuesConfig.menuItemTemplate({ original: groupContextIssue })).toMatchSnapshot(); + }); + + it('shows the iid and title in the menu item within a project context', () => { + expect(issuesConfig.menuItemTemplate({ original: projectContextIssue })).toMatchSnapshot(); + }); + + it('inserts the reference on autocomplete selection within a group context', () => { + expect(issuesConfig.selectTemplate({ original: groupContextIssue })).toBe( + groupContextIssue.reference, + ); + }); + + it('inserts the iid on autocomplete selection within a project context', () => { + expect(issuesConfig.selectTemplate({ original: projectContextIssue })).toBe( + `#${projectContextIssue.iid}`, + ); + }); + }); + + describe('labels config', () => { + const labelsConfig = tributeConfig[GfmAutocompleteType.Labels].config; + const labelsFilter = tributeConfig[GfmAutocompleteType.Labels].filterValues; + const label = { + color: '#123456', + textColor: '#FFFFFF', + title: `bug <script>alert('hi')</script>`, + type: 'GroupLabel', + }; + const singleWordLabel = { + color: '#456789', + textColor: '#DDD', + title: `bug`, + type: 'GroupLabel', + }; + const numericalLabel = { + color: '#abcdef', + textColor: '#AAA', + title: 123456, + type: 'ProjectLabel', + }; + + it('uses ~ as the trigger', () => { + expect(labelsConfig.trigger).toBe('~'); + }); + + it('searches using `title`', () => { + expect(labelsConfig.lookup).toBe('title'); + }); + + it('shows the title in the menu item', () => { + expect(labelsConfig.menuItemTemplate({ original: label })).toMatchSnapshot(); + }); + + it('inserts the title on autocomplete selection', () => { + expect(labelsConfig.selectTemplate({ original: singleWordLabel })).toBe( + `~${escape(singleWordLabel.title)}`, + ); + }); + + it('inserts the title enclosed with quotes on autocomplete selection when the title is numerical', () => { + expect(labelsConfig.selectTemplate({ original: numericalLabel })).toBe( + `~"${escape(numericalLabel.title)}"`, + ); + }); + + it('inserts the title enclosed with quotes on autocomplete selection when the title contains multiple words', () => { + expect(labelsConfig.selectTemplate({ original: label })).toBe(`~"${escape(label.title)}"`); + }); + + describe('filter', () => { + const collection = [label, singleWordLabel, { ...numericalLabel, set: true }]; + + describe('/label quick action', () => { + describe('when the line starts with `/label`', () => { + it('shows labels that are not currently selected', () => { + const fullText = '/label ~'; + const selectionStart = 8; + + expect(labelsFilter({ collection, fullText, selectionStart })).toEqual([ + collection[0], + collection[1], + ]); + }); + }); + + describe('when the line does not start with `/label`', () => { + it('shows all labels', () => { + const fullText = '~'; + const selectionStart = 1; + + expect(labelsFilter({ collection, fullText, selectionStart })).toEqual(collection); + }); + }); + }); + + describe('/unlabel quick action', () => { + describe('when the line starts with `/unlabel`', () => { + it('shows labels that are currently selected', () => { + const fullText = '/unlabel ~'; + const selectionStart = 10; + + expect(labelsFilter({ collection, fullText, selectionStart })).toEqual([collection[2]]); + }); + }); + + describe('when the line does not start with `/unlabel`', () => { + it('shows all labels', () => { + const fullText = '~'; + const selectionStart = 1; + + expect(labelsFilter({ collection, fullText, selectionStart })).toEqual(collection); + }); + }); + }); + }); + }); + + describe('members config', () => { + const membersConfig = tributeConfig[GfmAutocompleteType.Members].config; + const membersFilter = tributeConfig[GfmAutocompleteType.Members].filterValues; + const userMember = { + type: 'User', + username: 'myusername', + name: "My Name <script>alert('hi')</script>", + avatar_url: '/uploads/-/system/user/avatar/123456/avatar.png', + availability: null, + }; + const groupMember = { + type: 'Group', + username: 'gitlab-com/support/1-1s', + name: "GitLab.com / GitLab Support Team / 1-1s <script>alert('hi')</script>", + avatar_url: null, + count: 2, + mentionsDisabled: null, + }; + + it('uses @ as the trigger', () => { + expect(membersConfig.trigger).toBe('@'); + }); + + it('inserts the username on autocomplete selection', () => { + expect(membersConfig.fillAttr).toBe('username'); + }); + + it('searches using both the name and username for a user', () => { + expect(membersConfig.lookup(userMember)).toBe(`${userMember.name}${userMember.username}`); + }); + + it('searches using only its own name and not its ancestors for a group', () => { + expect(membersConfig.lookup(groupMember)).toBe(last(groupMember.name.split(' / '))); + }); + + it('shows the avatar, name and username in the menu item for a user', () => { + expect(membersConfig.menuItemTemplate({ original: userMember })).toMatchSnapshot(); + }); + + it('shows an avatar character, name, parent name, and count in the menu item for a group', () => { + expect(membersConfig.menuItemTemplate({ original: groupMember })).toMatchSnapshot(); + }); + + describe('filter', () => { + const assignees = [userMember.username]; + const collection = [userMember, groupMember]; + + describe('/assign quick action', () => { + describe('when the line starts with `/assign`', () => { + it('shows members that are not currently selected', () => { + const fullText = '/assign @'; + const selectionStart = 9; + + expect(membersFilter({ assignees, collection, fullText, selectionStart })).toEqual([ + collection[1], + ]); + }); + }); + + describe('when the line does not start with `/assign`', () => { + it('shows all labels', () => { + const fullText = '@'; + const selectionStart = 1; + + expect(membersFilter({ assignees, collection, fullText, selectionStart })).toEqual( + collection, + ); + }); + }); + }); + + describe('/unassign quick action', () => { + describe('when the line starts with `/unassign`', () => { + it('shows members that are currently selected', () => { + const fullText = '/unassign @'; + const selectionStart = 11; + + expect(membersFilter({ assignees, collection, fullText, selectionStart })).toEqual([ + collection[0], + ]); + }); + }); + + describe('when the line does not start with `/unassign`', () => { + it('shows all members', () => { + const fullText = '@'; + const selectionStart = 1; + + expect(membersFilter({ assignees, collection, fullText, selectionStart })).toEqual( + collection, + ); + }); + }); + }); + }); + }); + + describe('merge requests config', () => { + const mergeRequestsConfig = tributeConfig[GfmAutocompleteType.MergeRequests].config; + const groupContextMergeRequest = { + iid: 456789, + reference: 'gitlab!456789', + title: "Group context merge request title <script>alert('hi')</script>", + }; + const projectContextMergeRequest = { + id: null, + iid: 123456, + time_estimate: 0, + title: "Project context merge request title <script>alert('hi')</script>", + }; + + it('uses ! as the trigger', () => { + expect(mergeRequestsConfig.trigger).toBe('!'); + }); + + it('searches using both the iid and title', () => { + expect(mergeRequestsConfig.lookup(projectContextMergeRequest)).toBe( + `${projectContextMergeRequest.iid}${projectContextMergeRequest.title}`, + ); + }); + + it('shows the reference and title in the menu item within a group context', () => { + expect( + mergeRequestsConfig.menuItemTemplate({ original: groupContextMergeRequest }), + ).toMatchSnapshot(); + }); + + it('shows the iid and title in the menu item within a project context', () => { + expect( + mergeRequestsConfig.menuItemTemplate({ original: projectContextMergeRequest }), + ).toMatchSnapshot(); + }); + + it('inserts the reference on autocomplete selection within a group context', () => { + expect(mergeRequestsConfig.selectTemplate({ original: groupContextMergeRequest })).toBe( + groupContextMergeRequest.reference, + ); + }); + + it('inserts the iid on autocomplete selection within a project context', () => { + expect(mergeRequestsConfig.selectTemplate({ original: projectContextMergeRequest })).toBe( + `!${projectContextMergeRequest.iid}`, + ); + }); + }); + + describe('milestones config', () => { + const milestonesConfig = tributeConfig[GfmAutocompleteType.Milestones].config; + const milestone = { + id: null, + iid: 49, + title: "13.2 <script>alert('hi')</script>", + }; + + it('uses % as the trigger', () => { + expect(milestonesConfig.trigger).toBe('%'); + }); + + it('searches using the title', () => { + expect(milestonesConfig.lookup).toBe('title'); + }); + + it('shows the title in the menu item', () => { + expect(milestonesConfig.menuItemTemplate({ original: milestone })).toMatchSnapshot(); + }); + + it('inserts the title on autocomplete selection', () => { + expect(milestonesConfig.selectTemplate({ original: milestone })).toBe( + `%"${escape(milestone.title)}"`, + ); + }); + }); + + describe('snippets config', () => { + const snippetsConfig = tributeConfig[GfmAutocompleteType.Snippets].config; + const snippet = { + id: 123456, + title: "Snippet title <script>alert('hi')</script>", + }; + + it('uses $ as the trigger', () => { + expect(snippetsConfig.trigger).toBe('$'); + }); + + it('inserts the id on autocomplete selection', () => { + expect(snippetsConfig.fillAttr).toBe('id'); + }); + + it('searches using both the id and title', () => { + expect(snippetsConfig.lookup(snippet)).toBe(`${snippet.id}${snippet.title}`); + }); + + it('shows the id and title in the menu item', () => { + expect(snippetsConfig.menuItemTemplate({ original: snippet })).toMatchSnapshot(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js index c87d19df1f7..d1bfc180082 100644 --- a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js @@ -33,28 +33,31 @@ describe('IssueMilestoneComponent', () => { describe('computed', () => { describe('isMilestoneStarted', () => { - it('should return `false` when milestoneStart prop is not defined', () => { + it('should return `false` when milestoneStart prop is not defined', async () => { wrapper.setProps({ milestone: { ...mockMilestone, start_date: '' }, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.isMilestoneStarted).toBe(false); }); - it('should return `true` when milestone start date is past current date', () => { - wrapper.setProps({ + it('should return `true` when milestone start date is past current date', async () => { + await wrapper.setProps({ milestone: { ...mockMilestone, start_date: '1990-07-22' }, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.isMilestoneStarted).toBe(true); }); }); describe('isMilestonePastDue', () => { - it('should return `false` when milestoneDue prop is not defined', () => { + it('should return `false` when milestoneDue prop is not defined', async () => { wrapper.setProps({ milestone: { ...mockMilestone, due_date: '' }, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.isMilestonePastDue).toBe(false); }); @@ -73,41 +76,45 @@ describe('IssueMilestoneComponent', () => { expect(vm.milestoneDatesAbsolute).toBe('(December 31, 2019)'); }); - it('returns string containing absolute milestone start date when due date is not present', () => { + it('returns string containing absolute milestone start date when due date is not present', async () => { wrapper.setProps({ milestone: { ...mockMilestone, due_date: '' }, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.milestoneDatesAbsolute).toBe('(January 1, 2018)'); }); - it('returns empty string when both milestone start and due dates are not present', () => { + it('returns empty string when both milestone start and due dates are not present', async () => { wrapper.setProps({ milestone: { ...mockMilestone, start_date: '', due_date: '' }, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.milestoneDatesAbsolute).toBe(''); }); }); describe('milestoneDatesHuman', () => { - it('returns string containing milestone due date when date is yet to be due', () => { + it('returns string containing milestone due date when date is yet to be due', async () => { wrapper.setProps({ milestone: { ...mockMilestone, due_date: `${new Date().getFullYear() + 10}-01-01` }, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.milestoneDatesHuman).toContain('years remaining'); }); - it('returns string containing milestone start date when date has already started and due date is not present', () => { + it('returns string containing milestone start date when date has already started and due date is not present', async () => { wrapper.setProps({ milestone: { ...mockMilestone, start_date: '1990-07-22', due_date: '' }, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.milestoneDatesHuman).toContain('Started'); }); - it('returns string containing milestone start date when date is yet to start and due date is not present', () => { + it('returns string containing milestone start date when date is yet to start and due date is not present', async () => { wrapper.setProps({ milestone: { ...mockMilestone, @@ -115,14 +122,16 @@ describe('IssueMilestoneComponent', () => { due_date: '', }, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.milestoneDatesHuman).toContain('Starts'); }); - it('returns empty string when milestone start and due dates are not present', () => { + it('returns empty string when milestone start and due dates are not present', async () => { wrapper.setProps({ milestone: { ...mockMilestone, start_date: '', due_date: '' }, }); + await wrapper.vm.$nextTick(); expect(wrapper.vm.milestoneDatesHuman).toBe(''); }); diff --git a/spec/frontend/vue_shared/components/loading_button_spec.js b/spec/frontend/vue_shared/components/loading_button_spec.js deleted file mode 100644 index 8bcb80d140e..00000000000 --- a/spec/frontend/vue_shared/components/loading_button_spec.js +++ /dev/null @@ -1,100 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; - -const LABEL = 'Hello'; - -describe('LoadingButton', () => { - let wrapper; - - afterEach(() => { - wrapper.destroy(); - }); - - const buildWrapper = (propsData = {}) => { - wrapper = shallowMount(LoadingButton, { - propsData, - }); - }; - const findButtonLabel = () => wrapper.find('.js-loading-button-label'); - const findButtonIcon = () => wrapper.find('.js-loading-button-icon'); - - describe('loading spinner', () => { - it('shown when loading', () => { - buildWrapper({ loading: true }); - - expect(findButtonIcon().exists()).toBe(true); - }); - }); - - describe('disabled state', () => { - it('disabled when loading', () => { - buildWrapper({ loading: true }); - expect(wrapper.attributes('disabled')).toBe('disabled'); - }); - - it('not disabled when normal', () => { - buildWrapper({ loading: false }); - - expect(wrapper.attributes('disabled')).toBe(undefined); - }); - }); - - describe('label', () => { - it('shown when normal', () => { - buildWrapper({ loading: false, label: LABEL }); - expect(findButtonLabel().text()).toBe(LABEL); - }); - - it('shown when loading', () => { - buildWrapper({ loading: false, label: LABEL }); - expect(findButtonLabel().text()).toBe(LABEL); - }); - }); - - describe('container class', () => { - it('should default to btn btn-align-content', () => { - buildWrapper(); - - expect(wrapper.classes()).toContain('btn'); - expect(wrapper.classes()).toContain('btn-align-content'); - }); - - it('should be configurable through props', () => { - const containerClass = 'test-class'; - - buildWrapper({ - containerClass, - }); - - expect(wrapper.classes()).not.toContain('btn'); - expect(wrapper.classes()).not.toContain('btn-align-content'); - expect(wrapper.classes()).toContain(containerClass); - }); - }); - - describe('click callback prop', () => { - it('calls given callback when normal', () => { - buildWrapper({ - loading: false, - }); - - wrapper.trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('click')).toBeTruthy(); - }); - }); - - it('does not call given callback when disabled because of loading', () => { - buildWrapper({ - loading: true, - }); - - wrapper.trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('click')).toBeFalsy(); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js b/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js new file mode 100644 index 00000000000..0598506891b --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js @@ -0,0 +1,72 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlDropdown, GlFormTextarea, GlButton } from '@gitlab/ui'; +import ApplySuggestionComponent from '~/vue_shared/components/markdown/apply_suggestion.vue'; + +describe('Apply Suggestion component', () => { + const propsData = { fileName: 'test.js', disabled: false }; + let wrapper; + + const createWrapper = props => { + wrapper = shallowMount(ApplySuggestionComponent, { propsData: { ...propsData, ...props } }); + }; + + const findDropdown = () => wrapper.find(GlDropdown); + const findTextArea = () => wrapper.find(GlFormTextarea); + const findApplyButton = () => wrapper.find(GlButton); + + beforeEach(() => createWrapper()); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('initial template', () => { + it('renders a dropdown with the correct props', () => { + const dropdown = findDropdown(); + + expect(dropdown.exists()).toBe(true); + expect(dropdown.props('text')).toBe('Apply suggestion'); + expect(dropdown.props('headerText')).toBe('Apply suggestion commit message'); + expect(dropdown.props('disabled')).toBe(false); + }); + + it('renders a textarea with the correct props', () => { + const textArea = findTextArea(); + + expect(textArea.exists()).toBe(true); + expect(textArea.attributes('placeholder')).toBe('Apply suggestion on test.js'); + }); + + it('renders an apply button', () => { + const applyButton = findApplyButton(); + + expect(applyButton.exists()).toBe(true); + expect(applyButton.text()).toBe('Apply'); + }); + }); + + describe('disabled', () => { + it('disables the dropdown', () => { + createWrapper({ disabled: true }); + + expect(findDropdown().props('disabled')).toBe(true); + }); + }); + + describe('apply suggestion', () => { + it('emits an apply event with a default message if no message was added', () => { + findTextArea().vm.$emit('input', null); + findApplyButton().vm.$emit('click'); + + expect(wrapper.emitted('apply')).toEqual([['Apply suggestion on test.js']]); + }); + + it('emits an apply event with a user-defined message', () => { + findTextArea().vm.$emit('input', 'some text'); + findApplyButton().vm.$emit('click'); + + expect(wrapper.emitted('apply')).toEqual([['some text']]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/access_request_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/access_request_action_buttons_spec.js deleted file mode 100644 index 58cb8ef61d1..00000000000 --- a/spec/frontend/vue_shared/components/members/action_buttons/access_request_action_buttons_spec.js +++ /dev/null @@ -1,108 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import AccessRequestActionButtons from '~/vue_shared/components/members/action_buttons/access_request_action_buttons.vue'; -import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue'; -import ApproveAccessRequestButton from '~/vue_shared/components/members/action_buttons/approve_access_request_button.vue'; -import { accessRequest as member } from '../mock_data'; - -describe('AccessRequestActionButtons', () => { - let wrapper; - - const createComponent = (propsData = {}) => { - wrapper = shallowMount(AccessRequestActionButtons, { - propsData: { - member, - isCurrentUser: true, - ...propsData, - }, - }); - }; - - const findRemoveMemberButton = () => wrapper.find(RemoveMemberButton); - const findApproveButton = () => wrapper.find(ApproveAccessRequestButton); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('when user has `canRemove` permissions', () => { - beforeEach(() => { - createComponent({ - permissions: { - canRemove: true, - }, - }); - }); - - it('renders remove member button', () => { - expect(findRemoveMemberButton().exists()).toBe(true); - }); - - it('sets props correctly', () => { - expect(findRemoveMemberButton().props()).toMatchObject({ - memberId: member.id, - title: 'Deny access', - isAccessRequest: true, - icon: 'close', - }); - }); - - describe('when member is the current user', () => { - it('sets `message` prop correctly', () => { - expect(findRemoveMemberButton().props('message')).toBe( - `Are you sure you want to withdraw your access request for "${member.source.name}"`, - ); - }); - }); - - describe('when member is not the current user', () => { - it('sets `message` prop correctly', () => { - createComponent({ - isCurrentUser: false, - permissions: { - canRemove: true, - }, - }); - - expect(findRemoveMemberButton().props('message')).toBe( - `Are you sure you want to deny ${member.user.name}'s request to join "${member.source.name}"`, - ); - }); - }); - }); - - describe('when user does not have `canRemove` permissions', () => { - it('does not render remove member button', () => { - createComponent({ - permissions: { - canRemove: false, - }, - }); - - expect(findRemoveMemberButton().exists()).toBe(false); - }); - }); - - describe('when user has `canUpdate` permissions', () => { - it('renders the approve button', () => { - createComponent({ - permissions: { - canUpdate: true, - }, - }); - - expect(findApproveButton().exists()).toBe(true); - }); - }); - - describe('when user does not have `canUpdate` permissions', () => { - it('does not render the approve button', () => { - createComponent({ - permissions: { - canUpdate: false, - }, - }); - - expect(findApproveButton().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/approve_access_request_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/approve_access_request_button_spec.js deleted file mode 100644 index 93edaaa400d..00000000000 --- a/spec/frontend/vue_shared/components/members/action_buttons/approve_access_request_button_spec.js +++ /dev/null @@ -1,74 +0,0 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import { GlButton, GlForm } from '@gitlab/ui'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import ApproveAccessRequestButton from '~/vue_shared/components/members/action_buttons/approve_access_request_button.vue'; - -jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('ApproveAccessRequestButton', () => { - let wrapper; - - const createStore = (state = {}) => { - return new Vuex.Store({ - state: { - memberPath: '/groups/foo-bar/-/group_members/:id', - ...state, - }, - }); - }; - - const createComponent = (propsData = {}, state) => { - wrapper = shallowMount(ApproveAccessRequestButton, { - localVue, - store: createStore(state), - propsData: { - memberId: 1, - ...propsData, - }, - directives: { - GlTooltip: createMockDirective(), - }, - }); - }; - - const findForm = () => wrapper.find(GlForm); - const findButton = () => findForm().find(GlButton); - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('displays a tooltip', () => { - const button = findButton(); - - expect(getBinding(button.element, 'gl-tooltip')).not.toBeUndefined(); - expect(button.attributes('title')).toBe('Grant access'); - }); - - it('sets `aria-label` attribute', () => { - expect(findButton().attributes('aria-label')).toBe('Grant access'); - }); - - it('submits the form when button is clicked', () => { - expect(findButton().attributes('type')).toBe('submit'); - }); - - it('displays form with correct action and inputs', () => { - const form = findForm(); - - expect(form.attributes('action')).toBe( - '/groups/foo-bar/-/group_members/1/approve_access_request', - ); - expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe( - 'mock-csrf-token', - ); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js deleted file mode 100644 index 1374cdc6aef..00000000000 --- a/spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js +++ /dev/null @@ -1,85 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import InviteActionButtons from '~/vue_shared/components/members/action_buttons/invite_action_buttons.vue'; -import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue'; -import ResendInviteButton from '~/vue_shared/components/members/action_buttons/resend_invite_button.vue'; -import { invite as member } from '../mock_data'; - -describe('InviteActionButtons', () => { - let wrapper; - - const createComponent = (propsData = {}) => { - wrapper = shallowMount(InviteActionButtons, { - propsData: { - member, - ...propsData, - }, - }); - }; - - const findRemoveMemberButton = () => wrapper.find(RemoveMemberButton); - const findResendInviteButton = () => wrapper.find(ResendInviteButton); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('when user has `canRemove` permissions', () => { - beforeEach(() => { - createComponent({ - permissions: { - canRemove: true, - }, - }); - }); - - it('renders remove member button', () => { - expect(findRemoveMemberButton().exists()).toBe(true); - }); - - it('sets props correctly', () => { - expect(findRemoveMemberButton().props()).toEqual({ - memberId: member.id, - message: `Are you sure you want to revoke the invitation for ${member.invite.email} to join "${member.source.name}"`, - title: 'Revoke invite', - isAccessRequest: false, - icon: 'remove', - }); - }); - }); - - describe('when user does not have `canRemove` permissions', () => { - it('does not render remove member button', () => { - createComponent({ - permissions: { - canRemove: false, - }, - }); - - expect(findRemoveMemberButton().exists()).toBe(false); - }); - }); - - describe('when user has `canResend` permissions', () => { - it('renders resend invite button', () => { - createComponent({ - permissions: { - canResend: true, - }, - }); - - expect(findResendInviteButton().exists()).toBe(true); - }); - }); - - describe('when user does not have `canResend` permissions', () => { - it('does not render resend invite button', () => { - createComponent({ - permissions: { - canResend: false, - }, - }); - - expect(findResendInviteButton().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/leave_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/leave_button_spec.js deleted file mode 100644 index 00896b23b95..00000000000 --- a/spec/frontend/vue_shared/components/members/action_buttons/leave_button_spec.js +++ /dev/null @@ -1,59 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlButton } from '@gitlab/ui'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import LeaveButton from '~/vue_shared/components/members/action_buttons/leave_button.vue'; -import LeaveModal from '~/vue_shared/components/members/modals/leave_modal.vue'; -import { LEAVE_MODAL_ID } from '~/vue_shared/components/members/constants'; -import { member } from '../mock_data'; - -describe('LeaveButton', () => { - let wrapper; - - const createComponent = (propsData = {}) => { - wrapper = shallowMount(LeaveButton, { - propsData: { - member, - ...propsData, - }, - directives: { - GlTooltip: createMockDirective(), - GlModal: createMockDirective(), - }, - }); - }; - - const findButton = () => wrapper.find(GlButton); - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('displays a tooltip', () => { - const button = findButton(); - - expect(getBinding(button.element, 'gl-tooltip')).not.toBeUndefined(); - expect(button.attributes('title')).toBe('Leave'); - }); - - it('sets `aria-label` attribute', () => { - expect(findButton().attributes('aria-label')).toBe('Leave'); - }); - - it('renders leave modal', () => { - const leaveModal = wrapper.find(LeaveModal); - - expect(leaveModal.exists()).toBe(true); - expect(leaveModal.props('member')).toEqual(member); - }); - - it('triggers leave modal', () => { - const binding = getBinding(findButton().element, 'gl-modal'); - - expect(binding).not.toBeUndefined(); - expect(binding.value).toBe(LEAVE_MODAL_ID); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/remove_group_link_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/remove_group_link_button_spec.js deleted file mode 100644 index 84fe1c51773..00000000000 --- a/spec/frontend/vue_shared/components/members/action_buttons/remove_group_link_button_spec.js +++ /dev/null @@ -1,64 +0,0 @@ -import { mount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import { GlButton } from '@gitlab/ui'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import RemoveGroupLinkButton from '~/vue_shared/components/members/action_buttons/remove_group_link_button.vue'; -import { group } from '../mock_data'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('RemoveGroupLinkButton', () => { - let wrapper; - - const actions = { - showRemoveGroupLinkModal: jest.fn(), - }; - - const createStore = () => { - return new Vuex.Store({ - actions, - }); - }; - - const createComponent = () => { - wrapper = mount(RemoveGroupLinkButton, { - localVue, - store: createStore(), - propsData: { - groupLink: group, - }, - directives: { - GlTooltip: createMockDirective(), - }, - }); - }; - - const findButton = () => wrapper.find(GlButton); - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('displays a tooltip', () => { - const button = findButton(); - - expect(getBinding(button.element, 'gl-tooltip')).not.toBeUndefined(); - expect(button.attributes('title')).toBe('Remove group'); - }); - - it('sets `aria-label` attribute', () => { - expect(findButton().attributes('aria-label')).toBe('Remove group'); - }); - - it('calls Vuex action to open remove group link modal when clicked', () => { - findButton().trigger('click'); - - expect(actions.showRemoveGroupLinkModal).toHaveBeenCalledWith(expect.any(Object), group); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/remove_member_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/remove_member_button_spec.js deleted file mode 100644 index 7aa30494234..00000000000 --- a/spec/frontend/vue_shared/components/members/action_buttons/remove_member_button_spec.js +++ /dev/null @@ -1,66 +0,0 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('RemoveMemberButton', () => { - let wrapper; - - const createStore = (state = {}) => { - return new Vuex.Store({ - state: { - memberPath: '/groups/foo-bar/-/group_members/:id', - ...state, - }, - }); - }; - - const createComponent = (propsData = {}, state) => { - wrapper = shallowMount(RemoveMemberButton, { - localVue, - store: createStore(state), - propsData: { - memberId: 1, - message: 'Are you sure you want to remove John Smith?', - title: 'Remove member', - isAccessRequest: true, - ...propsData, - }, - directives: { - GlTooltip: createMockDirective(), - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it('sets attributes on button', () => { - createComponent(); - - expect(wrapper.attributes()).toMatchObject({ - 'data-member-path': '/groups/foo-bar/-/group_members/1', - 'data-message': 'Are you sure you want to remove John Smith?', - 'data-is-access-request': 'true', - 'aria-label': 'Remove member', - title: 'Remove member', - icon: 'remove', - }); - }); - - it('displays `title` prop as a tooltip', () => { - createComponent(); - - expect(getBinding(wrapper.element, 'gl-tooltip')).not.toBeUndefined(); - }); - - it('has CSS class used by `remove_member_modal.vue`', () => { - createComponent(); - - expect(wrapper.classes()).toContain('js-remove-member-button'); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/resend_invite_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/resend_invite_button_spec.js deleted file mode 100644 index 859fdd01043..00000000000 --- a/spec/frontend/vue_shared/components/members/action_buttons/resend_invite_button_spec.js +++ /dev/null @@ -1,66 +0,0 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import { GlButton } from '@gitlab/ui'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import ResendInviteButton from '~/vue_shared/components/members/action_buttons/resend_invite_button.vue'; - -jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('ResendInviteButton', () => { - let wrapper; - - const createStore = (state = {}) => { - return new Vuex.Store({ - state: { - memberPath: '/groups/foo-bar/-/group_members/:id', - ...state, - }, - }); - }; - - const createComponent = (propsData = {}, state) => { - wrapper = shallowMount(ResendInviteButton, { - localVue, - store: createStore(state), - propsData: { - memberId: 1, - ...propsData, - }, - directives: { - GlTooltip: createMockDirective(), - }, - }); - }; - - const findForm = () => wrapper.find('form'); - const findButton = () => findForm().find(GlButton); - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('displays a tooltip', () => { - expect(getBinding(findButton().element, 'gl-tooltip')).not.toBeUndefined(); - expect(findButton().attributes('title')).toBe('Resend invite'); - }); - - it('submits the form when button is clicked', () => { - expect(findButton().attributes('type')).toBe('submit'); - }); - - it('displays form with correct action and inputs', () => { - expect(findForm().attributes('action')).toBe('/groups/foo-bar/-/group_members/1/resend_invite'); - expect( - findForm() - .find('input[name="authenticity_token"]') - .attributes('value'), - ).toBe('mock-csrf-token'); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/user_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/user_action_buttons_spec.js deleted file mode 100644 index f766ad5b0d1..00000000000 --- a/spec/frontend/vue_shared/components/members/action_buttons/user_action_buttons_spec.js +++ /dev/null @@ -1,89 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import UserActionButtons from '~/vue_shared/components/members/action_buttons/user_action_buttons.vue'; -import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue'; -import LeaveButton from '~/vue_shared/components/members/action_buttons/leave_button.vue'; -import { member, orphanedMember } from '../mock_data'; - -describe('UserActionButtons', () => { - let wrapper; - - const createComponent = (propsData = {}) => { - wrapper = shallowMount(UserActionButtons, { - propsData: { - member, - isCurrentUser: false, - ...propsData, - }, - }); - }; - - const findRemoveMemberButton = () => wrapper.find(RemoveMemberButton); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('when user has `canRemove` permissions', () => { - beforeEach(() => { - createComponent({ - permissions: { - canRemove: true, - }, - }); - }); - - it('renders remove member button', () => { - expect(findRemoveMemberButton().exists()).toBe(true); - }); - - it('sets props correctly', () => { - expect(findRemoveMemberButton().props()).toEqual({ - memberId: member.id, - message: `Are you sure you want to remove ${member.user.name} from "${member.source.name}"`, - title: 'Remove member', - isAccessRequest: false, - icon: 'remove', - }); - }); - - describe('when member is orphaned', () => { - it('sets `message` prop correctly', () => { - createComponent({ - member: orphanedMember, - permissions: { - canRemove: true, - }, - }); - - expect(findRemoveMemberButton().props('message')).toBe( - `Are you sure you want to remove this orphaned member from "${orphanedMember.source.name}"`, - ); - }); - }); - - describe('when member is the current user', () => { - it('renders leave button', () => { - createComponent({ - isCurrentUser: true, - permissions: { - canRemove: true, - }, - }); - - expect(wrapper.find(LeaveButton).exists()).toBe(true); - }); - }); - }); - - describe('when user does not have `canRemove` permissions', () => { - it('does not render remove member button', () => { - createComponent({ - permissions: { - canRemove: false, - }, - }); - - expect(findRemoveMemberButton().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js deleted file mode 100644 index d6f5773295c..00000000000 --- a/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js +++ /dev/null @@ -1,46 +0,0 @@ -import { mount, createWrapper } from '@vue/test-utils'; -import { getByText as getByTextHelper } from '@testing-library/dom'; -import { GlAvatarLink } from '@gitlab/ui'; -import { group as member } from '../mock_data'; -import GroupAvatar from '~/vue_shared/components/members/avatars/group_avatar.vue'; - -describe('MemberList', () => { - let wrapper; - - const group = member.sharedWithGroup; - - const createComponent = (propsData = {}) => { - wrapper = mount(GroupAvatar, { - propsData: { - member, - ...propsData, - }, - }); - }; - - const getByText = (text, options) => - createWrapper(getByTextHelper(wrapper.element, text, options)); - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders link to group', () => { - const link = wrapper.find(GlAvatarLink); - - expect(link.exists()).toBe(true); - expect(link.attributes('href')).toBe(group.webUrl); - }); - - it("renders group's full name", () => { - expect(getByText(group.fullName).exists()).toBe(true); - }); - - it("renders group's avatar", () => { - expect(wrapper.find('img').attributes('src')).toBe(group.avatarUrl); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js deleted file mode 100644 index 7948da7eb40..00000000000 --- a/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js +++ /dev/null @@ -1,38 +0,0 @@ -import { mount, createWrapper } from '@vue/test-utils'; -import { getByText as getByTextHelper } from '@testing-library/dom'; -import { invite as member } from '../mock_data'; -import InviteAvatar from '~/vue_shared/components/members/avatars/invite_avatar.vue'; - -describe('MemberList', () => { - let wrapper; - - const { invite } = member; - - const createComponent = (propsData = {}) => { - wrapper = mount(InviteAvatar, { - propsData: { - member, - ...propsData, - }, - }); - }; - - const getByText = (text, options) => - createWrapper(getByTextHelper(wrapper.element, text, options)); - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders email as name', () => { - expect(getByText(invite.email).exists()).toBe(true); - }); - - it('renders avatar', () => { - expect(wrapper.find('img').attributes('src')).toBe(invite.avatarUrl); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js deleted file mode 100644 index 93d8e640968..00000000000 --- a/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js +++ /dev/null @@ -1,115 +0,0 @@ -import { mount, createWrapper } from '@vue/test-utils'; -import { within } from '@testing-library/dom'; -import { GlAvatarLink, GlBadge } from '@gitlab/ui'; -import { member as memberMock, orphanedMember } from '../mock_data'; -import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue'; - -describe('UserAvatar', () => { - let wrapper; - - const { user } = memberMock; - - const createComponent = (propsData = {}) => { - wrapper = mount(UserAvatar, { - propsData: { - member: memberMock, - isCurrentUser: false, - ...propsData, - }, - }); - }; - - const getByText = (text, options) => - createWrapper(within(wrapper.element).findByText(text, options)); - - const findStatusEmoji = emoji => wrapper.find(`gl-emoji[data-name="${emoji}"]`); - - afterEach(() => { - wrapper.destroy(); - }); - - it("renders link to user's profile", () => { - createComponent(); - - const link = wrapper.find(GlAvatarLink); - - expect(link.exists()).toBe(true); - expect(link.attributes()).toMatchObject({ - href: user.webUrl, - 'data-user-id': `${user.id}`, - 'data-username': user.username, - }); - }); - - it("renders user's name", () => { - createComponent(); - - expect(getByText(user.name).exists()).toBe(true); - }); - - it("renders user's username", () => { - createComponent(); - - expect(getByText(`@${user.username}`).exists()).toBe(true); - }); - - it("renders user's avatar", () => { - createComponent(); - - expect(wrapper.find('img').attributes('src')).toBe(user.avatarUrl); - }); - - describe('when user property does not exist', () => { - it('displays an orphaned user', () => { - createComponent({ member: orphanedMember }); - - expect(getByText('Orphaned member').exists()).toBe(true); - }); - }); - - describe('badges', () => { - it.each` - member | badgeText - ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${'Blocked'} - ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${'2FA'} - `('renders the "$badgeText" badge', ({ member, badgeText }) => { - createComponent({ member }); - - expect(wrapper.find(GlBadge).text()).toBe(badgeText); - }); - - it('renders the "It\'s you" badge when member is current user', () => { - createComponent({ isCurrentUser: true }); - - expect(getByText("It's you").exists()).toBe(true); - }); - }); - - describe('user status', () => { - const emoji = 'island'; - - describe('when set', () => { - it('displays the status emoji', () => { - createComponent({ - member: { - ...memberMock, - user: { - ...memberMock.user, - status: { emoji, messageHtml: 'On vacation' }, - }, - }, - }); - - expect(findStatusEmoji(emoji).exists()).toBe(true); - }); - }); - - describe('when not set', () => { - it('does not display status emoji', () => { - createComponent(); - - expect(findStatusEmoji(emoji).exists()).toBe(false); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/mock_data.js b/spec/frontend/vue_shared/components/members/mock_data.js deleted file mode 100644 index 5674929716d..00000000000 --- a/spec/frontend/vue_shared/components/members/mock_data.js +++ /dev/null @@ -1,71 +0,0 @@ -export const member = { - requestedAt: null, - canUpdate: false, - canRemove: false, - canOverride: false, - isOverridden: false, - accessLevel: { integerValue: 50, stringValue: 'Owner' }, - source: { - id: 178, - name: 'Foo Bar', - webUrl: 'https://gitlab.com/groups/foo-bar', - }, - user: { - id: 123, - name: 'Administrator', - username: 'root', - webUrl: 'https://gitlab.com/root', - avatarUrl: 'https://www.gravatar.com/avatar/4816142ef496f956a277bedf1a40607b?s=80&d=identicon', - blocked: false, - twoFactorEnabled: false, - }, - id: 238, - createdAt: '2020-07-17T16:22:46.923Z', - expiresAt: null, - usingLicense: false, - groupSso: false, - groupManagedAccount: false, - validRoles: { - Guest: 10, - Reporter: 20, - Developer: 30, - Maintainer: 40, - Owner: 50, - 'Minimal Access': 5, - }, -}; - -export const group = { - accessLevel: { integerValue: 10, stringValue: 'Guest' }, - sharedWithGroup: { - id: 24, - name: 'Commit451', - avatarUrl: '/uploads/-/system/user/avatar/1/avatar.png?width=40', - fullPath: 'parent-group/commit451', - fullName: 'Parent group / Commit451', - webUrl: 'https://gitlab.com/groups/parent-group/commit451', - }, - id: 3, - createdAt: '2020-08-06T15:31:07.662Z', - expiresAt: null, - validRoles: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }, -}; - -const { user, ...memberNoUser } = member; -export const invite = { - ...memberNoUser, - invite: { - email: 'jewel@hudsonwalter.biz', - avatarUrl: 'https://www.gravatar.com/avatar/cbab7510da7eec2f60f638261b05436d?s=80&d=identicon', - canResend: true, - }, -}; - -export const orphanedMember = memberNoUser; - -export const accessRequest = { - ...member, - requestedAt: '2020-07-17T16:22:46.923Z', -}; - -export const members = [member]; diff --git a/spec/frontend/vue_shared/components/members/modals/leave_modal_spec.js b/spec/frontend/vue_shared/components/members/modals/leave_modal_spec.js deleted file mode 100644 index 63de355a3c8..00000000000 --- a/spec/frontend/vue_shared/components/members/modals/leave_modal_spec.js +++ /dev/null @@ -1,91 +0,0 @@ -import { mount, createLocalVue, createWrapper } from '@vue/test-utils'; -import { GlModal, GlForm } from '@gitlab/ui'; -import { nextTick } from 'vue'; -import { within } from '@testing-library/dom'; -import Vuex from 'vuex'; -import LeaveModal from '~/vue_shared/components/members/modals/leave_modal.vue'; -import { LEAVE_MODAL_ID } from '~/vue_shared/components/members/constants'; -import { member } from '../mock_data'; - -jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('LeaveModal', () => { - let wrapper; - - const createStore = (state = {}) => { - return new Vuex.Store({ - state: { - memberPath: '/groups/foo-bar/-/group_members/:id', - ...state, - }, - }); - }; - - const createComponent = (propsData = {}, state) => { - wrapper = mount(LeaveModal, { - localVue, - store: createStore(state), - propsData: { - member, - ...propsData, - }, - attrs: { - static: true, - visible: true, - }, - }); - }; - - const findModal = () => wrapper.find(GlModal); - - const findForm = () => findModal().find(GlForm); - - const getByText = (text, options) => - createWrapper(within(findModal().element).getByText(text, options)); - - beforeEach(async () => { - createComponent(); - await nextTick(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('sets modal ID', () => { - expect(findModal().props('modalId')).toBe(LEAVE_MODAL_ID); - }); - - it('displays modal title', () => { - expect(getByText(`Leave "${member.source.name}"`).exists()).toBe(true); - }); - - it('displays modal body', () => { - expect(getByText(`Are you sure you want to leave "${member.source.name}"?`).exists()).toBe( - true, - ); - }); - - it('displays form with correct action and inputs', () => { - const form = findForm(); - - expect(form.attributes('action')).toBe('/groups/foo-bar/-/group_members/leave'); - expect(form.find('input[name="_method"]').attributes('value')).toBe('delete'); - expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe( - 'mock-csrf-token', - ); - }); - - it('submits the form when "Leave" button is clicked', () => { - const submitSpy = jest.spyOn(findForm().element, 'submit'); - - getByText('Leave').trigger('click'); - - expect(submitSpy).toHaveBeenCalled(); - - submitSpy.mockRestore(); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/modals/remove_group_link_modal_spec.js b/spec/frontend/vue_shared/components/members/modals/remove_group_link_modal_spec.js deleted file mode 100644 index 84da051792d..00000000000 --- a/spec/frontend/vue_shared/components/members/modals/remove_group_link_modal_spec.js +++ /dev/null @@ -1,106 +0,0 @@ -import { mount, createLocalVue, createWrapper } from '@vue/test-utils'; -import { GlModal, GlForm } from '@gitlab/ui'; -import { nextTick } from 'vue'; -import { within } from '@testing-library/dom'; -import Vuex from 'vuex'; -import RemoveGroupLinkModal from '~/vue_shared/components/members/modals/remove_group_link_modal.vue'; -import { REMOVE_GROUP_LINK_MODAL_ID } from '~/vue_shared/components/members/constants'; -import { group } from '../mock_data'; - -jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('RemoveGroupLinkModal', () => { - let wrapper; - - const actions = { - hideRemoveGroupLinkModal: jest.fn(), - }; - - const createStore = (state = {}) => { - return new Vuex.Store({ - state: { - memberPath: '/groups/foo-bar/-/group_links/:id', - groupLinkToRemove: group, - removeGroupLinkModalVisible: true, - ...state, - }, - actions, - }); - }; - - const createComponent = state => { - wrapper = mount(RemoveGroupLinkModal, { - localVue, - store: createStore(state), - attrs: { - static: true, - }, - }); - }; - - const findModal = () => wrapper.find(GlModal); - const findForm = () => findModal().find(GlForm); - const getByText = (text, options) => - createWrapper(within(findModal().element).getByText(text, options)); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('when modal is open', () => { - beforeEach(async () => { - createComponent(); - await nextTick(); - }); - - it('sets modal ID', () => { - expect(findModal().props('modalId')).toBe(REMOVE_GROUP_LINK_MODAL_ID); - }); - - it('displays modal title', () => { - expect(getByText(`Remove "${group.sharedWithGroup.fullName}"`).exists()).toBe(true); - }); - - it('displays modal body', () => { - expect( - getByText(`Are you sure you want to remove "${group.sharedWithGroup.fullName}"?`).exists(), - ).toBe(true); - }); - - it('displays form with correct action and inputs', () => { - const form = findForm(); - - expect(form.attributes('action')).toBe(`/groups/foo-bar/-/group_links/${group.id}`); - expect(form.find('input[name="_method"]').attributes('value')).toBe('delete'); - expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe( - 'mock-csrf-token', - ); - }); - - it('submits the form when "Remove group" button is clicked', () => { - const submitSpy = jest.spyOn(findForm().element, 'submit'); - - getByText('Remove group').trigger('click'); - - expect(submitSpy).toHaveBeenCalled(); - - submitSpy.mockRestore(); - }); - - it('calls `hideRemoveGroupLinkModal` action when modal is closed', () => { - getByText('Cancel').trigger('click'); - - expect(actions.hideRemoveGroupLinkModal).toHaveBeenCalled(); - }); - }); - - it('modal does not show when `removeGroupLinkModalVisible` is `false`', () => { - createComponent({ removeGroupLinkModalVisible: false }); - - expect(findModal().vm.$attrs.visible).toBe(false); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/table/created_at_spec.js b/spec/frontend/vue_shared/components/members/table/created_at_spec.js deleted file mode 100644 index cf3821baf44..00000000000 --- a/spec/frontend/vue_shared/components/members/table/created_at_spec.js +++ /dev/null @@ -1,61 +0,0 @@ -import { mount, createWrapper } from '@vue/test-utils'; -import { within } from '@testing-library/dom'; -import { useFakeDate } from 'helpers/fake_date'; -import CreatedAt from '~/vue_shared/components/members/table/created_at.vue'; -import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; - -describe('CreatedAt', () => { - // March 15th, 2020 - useFakeDate(2020, 2, 15); - - const date = '2020-03-01T00:00:00.000'; - const dateTimeAgo = '2 weeks ago'; - - let wrapper; - - const createComponent = propsData => { - wrapper = mount(CreatedAt, { - propsData: { - date, - ...propsData, - }, - }); - }; - - const getByText = (text, options) => - createWrapper(within(wrapper.element).getByText(text, options)); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('created at text', () => { - beforeEach(() => { - createComponent(); - }); - - it('displays created at text', () => { - expect(getByText(dateTimeAgo).exists()).toBe(true); - }); - - it('uses `TimeAgoTooltip` component to display tooltip', () => { - expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true); - }); - }); - - describe('when `createdBy` prop is provided', () => { - it('displays a link to the user that created the member', () => { - createComponent({ - createdBy: { - name: 'Administrator', - webUrl: 'https://gitlab.com/root', - }, - }); - - const link = getByText('Administrator'); - - expect(link.exists()).toBe(true); - expect(link.attributes('href')).toBe('https://gitlab.com/root'); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/table/expiration_datepicker_spec.js b/spec/frontend/vue_shared/components/members/table/expiration_datepicker_spec.js deleted file mode 100644 index a1afdbc2b49..00000000000 --- a/spec/frontend/vue_shared/components/members/table/expiration_datepicker_spec.js +++ /dev/null @@ -1,166 +0,0 @@ -import { mount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import { nextTick } from 'vue'; -import { GlDatepicker } from '@gitlab/ui'; -import { useFakeDate } from 'helpers/fake_date'; -import waitForPromises from 'helpers/wait_for_promises'; -import ExpirationDatepicker from '~/vue_shared/components/members/table/expiration_datepicker.vue'; -import { member } from '../mock_data'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('ExpirationDatepicker', () => { - // March 15th, 2020 3:00 - useFakeDate(2020, 2, 15, 3); - - let wrapper; - let actions; - let resolveUpdateMemberExpiration; - const $toast = { - show: jest.fn(), - }; - - const createStore = () => { - actions = { - updateMemberExpiration: jest.fn( - () => - new Promise(resolve => { - resolveUpdateMemberExpiration = resolve; - }), - ), - }; - - return new Vuex.Store({ actions }); - }; - - const createComponent = (propsData = {}) => { - wrapper = mount(ExpirationDatepicker, { - propsData: { - member, - permissions: { canUpdate: true }, - ...propsData, - }, - localVue, - store: createStore(), - mocks: { - $toast, - }, - }); - }; - - const findInput = () => wrapper.find('input'); - const findDatepicker = () => wrapper.find(GlDatepicker); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('datepicker input', () => { - it('sets `member.expiresAt` as initial date', async () => { - createComponent({ member: { ...member, expiresAt: '2020-03-17T00:00:00Z' } }); - - await nextTick(); - - expect(findInput().element.value).toBe('2020-03-17'); - }); - }); - - describe('props', () => { - beforeEach(() => { - createComponent(); - }); - - it('sets `minDate` prop as tomorrow', () => { - expect( - findDatepicker() - .props('minDate') - .toISOString(), - ).toBe(new Date('2020-3-16').toISOString()); - }); - - it('sets `target` prop as `null` so datepicker opens on focus', () => { - expect(findDatepicker().props('target')).toBe(null); - }); - - it("sets `container` prop as `null` so table styles don't affect the datepicker styles", () => { - expect(findDatepicker().props('container')).toBe(null); - }); - - it('shows clear button', () => { - expect(findDatepicker().props('showClearButton')).toBe(true); - }); - }); - - describe('when datepicker is changed', () => { - beforeEach(async () => { - createComponent(); - - findDatepicker().vm.$emit('input', new Date('2020-03-17')); - }); - - it('calls `updateMemberExpiration` Vuex action', () => { - expect(actions.updateMemberExpiration).toHaveBeenCalledWith(expect.any(Object), { - memberId: member.id, - expiresAt: new Date('2020-03-17'), - }); - }); - - it('displays toast when successful', async () => { - resolveUpdateMemberExpiration(); - await waitForPromises(); - - expect($toast.show).toHaveBeenCalledWith('Expiration date updated successfully.'); - }); - - it('disables dropdown while waiting for `updateMemberExpiration` to resolve', async () => { - expect(findDatepicker().props('disabled')).toBe(true); - - resolveUpdateMemberExpiration(); - await waitForPromises(); - - expect(findDatepicker().props('disabled')).toBe(false); - }); - }); - - describe('when datepicker is cleared', () => { - beforeEach(async () => { - createComponent(); - - findInput().setValue('2020-03-17'); - await nextTick(); - wrapper.find('[data-testid="clear-button"]').trigger('click'); - }); - - it('calls `updateMemberExpiration` Vuex action', () => { - expect(actions.updateMemberExpiration).toHaveBeenCalledWith(expect.any(Object), { - memberId: member.id, - expiresAt: null, - }); - }); - - it('displays toast when successful', async () => { - resolveUpdateMemberExpiration(); - await waitForPromises(); - - expect($toast.show).toHaveBeenCalledWith('Expiration date removed successfully.'); - }); - - it('disables datepicker while waiting for `updateMemberExpiration` to resolve', async () => { - expect(findDatepicker().props('disabled')).toBe(true); - - resolveUpdateMemberExpiration(); - await waitForPromises(); - - expect(findDatepicker().props('disabled')).toBe(false); - }); - }); - - describe('when user does not have `canUpdate` permissions', () => { - it('disables datepicker', () => { - createComponent({ permissions: { canUpdate: false } }); - - expect(findDatepicker().props('disabled')).toBe(true); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/table/expires_at_spec.js b/spec/frontend/vue_shared/components/members/table/expires_at_spec.js deleted file mode 100644 index 95ae251b0fd..00000000000 --- a/spec/frontend/vue_shared/components/members/table/expires_at_spec.js +++ /dev/null @@ -1,86 +0,0 @@ -import { mount, createWrapper } from '@vue/test-utils'; -import { within } from '@testing-library/dom'; -import { useFakeDate } from 'helpers/fake_date'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue'; - -describe('ExpiresAt', () => { - // March 15th, 2020 - useFakeDate(2020, 2, 15); - - let wrapper; - - const createComponent = propsData => { - wrapper = mount(ExpiresAt, { - propsData, - directives: { - GlTooltip: createMockDirective(), - }, - }); - }; - - const getByText = (text, options) => - createWrapper(within(wrapper.element).getByText(text, options)); - - const getTooltipDirective = elementWrapper => getBinding(elementWrapper.element, 'gl-tooltip'); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('when no expiration date is set', () => { - it('displays "No expiration set"', () => { - createComponent({ date: null }); - - expect(getByText('No expiration set').exists()).toBe(true); - }); - }); - - describe('when expiration date is in the past', () => { - let expiredText; - - beforeEach(() => { - createComponent({ date: '2019-03-15T00:00:00.000' }); - - expiredText = getByText('Expired'); - }); - - it('displays "Expired"', () => { - expect(expiredText.exists()).toBe(true); - expect(expiredText.classes()).toContain('gl-text-red-500'); - }); - - it('displays tooltip with formatted date', () => { - const tooltipDirective = getTooltipDirective(expiredText); - - expect(tooltipDirective).not.toBeUndefined(); - expect(expiredText.attributes('title')).toBe('Mar 15, 2019 12:00am GMT+0000'); - }); - }); - - describe('when expiration date is in the future', () => { - it.each` - date | expected | warningColor - ${'2020-03-23T00:00:00.000'} | ${'in 8 days'} | ${false} - ${'2020-03-20T00:00:00.000'} | ${'in 5 days'} | ${true} - ${'2020-03-16T00:00:00.000'} | ${'in 1 day'} | ${true} - ${'2020-03-15T05:00:00.000'} | ${'in about 5 hours'} | ${true} - ${'2020-03-15T01:00:00.000'} | ${'in about 1 hour'} | ${true} - ${'2020-03-15T00:30:00.000'} | ${'in 30 minutes'} | ${true} - ${'2020-03-15T00:01:15.000'} | ${'in 1 minute'} | ${true} - ${'2020-03-15T00:00:15.000'} | ${'in less than a minute'} | ${true} - `('displays "$expected"', ({ date, expected, warningColor }) => { - createComponent({ date }); - - const expiredText = getByText(expected); - - expect(expiredText.exists()).toBe(true); - - if (warningColor) { - expect(expiredText.classes()).toContain('gl-text-orange-500'); - } else { - expect(expiredText.classes()).not.toContain('gl-text-orange-500'); - } - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/table/member_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/table/member_action_buttons_spec.js deleted file mode 100644 index e55d9b6be2a..00000000000 --- a/spec/frontend/vue_shared/components/members/table/member_action_buttons_spec.js +++ /dev/null @@ -1,43 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { MEMBER_TYPES } from '~/vue_shared/components/members/constants'; -import { member as memberMock, group, invite, accessRequest } from '../mock_data'; -import MemberActionButtons from '~/vue_shared/components/members/table/member_action_buttons.vue'; -import UserActionButtons from '~/vue_shared/components/members/action_buttons/user_action_buttons.vue'; -import GroupActionButtons from '~/vue_shared/components/members/action_buttons/group_action_buttons.vue'; -import InviteActionButtons from '~/vue_shared/components/members/action_buttons/invite_action_buttons.vue'; -import AccessRequestActionButtons from '~/vue_shared/components/members/action_buttons/access_request_action_buttons.vue'; - -describe('MemberActionButtons', () => { - let wrapper; - - const createComponent = (propsData = {}) => { - wrapper = shallowMount(MemberActionButtons, { - propsData: { - isCurrentUser: false, - permissions: { - canRemove: true, - }, - ...propsData, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - test.each` - memberType | member | expectedComponent | expectedComponentName - ${MEMBER_TYPES.user} | ${memberMock} | ${UserActionButtons} | ${'UserActionButtons'} - ${MEMBER_TYPES.group} | ${group} | ${GroupActionButtons} | ${'GroupActionButtons'} - ${MEMBER_TYPES.invite} | ${invite} | ${InviteActionButtons} | ${'InviteActionButtons'} - ${MEMBER_TYPES.accessRequest} | ${accessRequest} | ${AccessRequestActionButtons} | ${'AccessRequestActionButtons'} - `( - 'renders $expectedComponentName when `memberType` is $memberType', - ({ memberType, member, expectedComponent }) => { - createComponent({ memberType, member }); - - expect(wrapper.find(expectedComponent).exists()).toBe(true); - }, - ); -}); diff --git a/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js b/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js deleted file mode 100644 index a171dd830c1..00000000000 --- a/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js +++ /dev/null @@ -1,39 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { MEMBER_TYPES } from '~/vue_shared/components/members/constants'; -import { member as memberMock, group, invite, accessRequest } from '../mock_data'; -import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue'; -import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue'; -import GroupAvatar from '~/vue_shared/components/members/avatars/group_avatar.vue'; -import InviteAvatar from '~/vue_shared/components/members/avatars/invite_avatar.vue'; - -describe('MemberList', () => { - let wrapper; - - const createComponent = propsData => { - wrapper = shallowMount(MemberAvatar, { - propsData: { - isCurrentUser: false, - ...propsData, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - test.each` - memberType | member | expectedComponent | expectedComponentName - ${MEMBER_TYPES.user} | ${memberMock} | ${UserAvatar} | ${'UserAvatar'} - ${MEMBER_TYPES.group} | ${group} | ${GroupAvatar} | ${'GroupAvatar'} - ${MEMBER_TYPES.invite} | ${invite} | ${InviteAvatar} | ${'InviteAvatar'} - ${MEMBER_TYPES.accessRequest} | ${accessRequest} | ${UserAvatar} | ${'UserAvatar'} - `( - 'renders $expectedComponentName when `memberType` is $memberType', - ({ memberType, member, expectedComponent }) => { - createComponent({ memberType, member }); - - expect(wrapper.find(expectedComponent).exists()).toBe(true); - }, - ); -}); diff --git a/spec/frontend/vue_shared/components/members/table/member_source_spec.js b/spec/frontend/vue_shared/components/members/table/member_source_spec.js deleted file mode 100644 index 8b914d76674..00000000000 --- a/spec/frontend/vue_shared/components/members/table/member_source_spec.js +++ /dev/null @@ -1,71 +0,0 @@ -import { mount, createWrapper } from '@vue/test-utils'; -import { getByText as getByTextHelper } from '@testing-library/dom'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import MemberSource from '~/vue_shared/components/members/table/member_source.vue'; - -describe('MemberSource', () => { - let wrapper; - - const createComponent = propsData => { - wrapper = mount(MemberSource, { - propsData: { - memberSource: { - id: 102, - name: 'Foo bar', - webUrl: 'https://gitlab.com/groups/foo-bar', - }, - ...propsData, - }, - directives: { - GlTooltip: createMockDirective(), - }, - }); - }; - - const getByText = (text, options) => - createWrapper(getByTextHelper(wrapper.element, text, options)); - - const getTooltipDirective = elementWrapper => getBinding(elementWrapper.element, 'gl-tooltip'); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('direct member', () => { - it('displays "Direct member"', () => { - createComponent({ - isDirectMember: true, - }); - - expect(getByText('Direct member').exists()).toBe(true); - }); - }); - - describe('inherited member', () => { - let sourceGroupLink; - - beforeEach(() => { - createComponent({ - isDirectMember: false, - }); - - sourceGroupLink = getByText('Foo bar'); - }); - - it('displays a link to source group', () => { - createComponent({ - isDirectMember: false, - }); - - expect(sourceGroupLink.exists()).toBe(true); - expect(sourceGroupLink.attributes('href')).toBe('https://gitlab.com/groups/foo-bar'); - }); - - it('displays tooltip with "Inherited"', () => { - const tooltipDirective = getTooltipDirective(sourceGroupLink); - - expect(tooltipDirective).not.toBeUndefined(); - expect(sourceGroupLink.attributes('title')).toBe('Inherited'); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js b/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js deleted file mode 100644 index ba693975a88..00000000000 --- a/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js +++ /dev/null @@ -1,251 +0,0 @@ -import { mount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import { MEMBER_TYPES } from '~/vue_shared/components/members/constants'; -import { member as memberMock, group, invite, accessRequest } from '../mock_data'; -import MembersTableCell from '~/vue_shared/components/members/table/members_table_cell.vue'; - -describe('MemberList', () => { - const WrappedComponent = { - props: { - memberType: { - type: String, - required: true, - }, - isDirectMember: { - type: Boolean, - required: true, - }, - isCurrentUser: { - type: Boolean, - required: true, - }, - permissions: { - type: Object, - required: true, - }, - }, - render(createElement) { - return createElement('div', this.memberType); - }, - }; - - const localVue = createLocalVue(); - localVue.use(Vuex); - localVue.component('wrapped-component', WrappedComponent); - - const createStore = (state = {}) => { - return new Vuex.Store({ - state: { - sourceId: 1, - currentUserId: 1, - ...state, - }, - }); - }; - - let wrapper; - - const createComponent = (propsData, state = {}) => { - wrapper = mount(MembersTableCell, { - localVue, - propsData, - store: createStore(state), - scopedSlots: { - default: ` - <wrapped-component - :member-type="props.memberType" - :is-direct-member="props.isDirectMember" - :is-current-user="props.isCurrentUser" - :permissions="props.permissions" - /> - `, - }, - }); - }; - - const findWrappedComponent = () => wrapper.find(WrappedComponent); - - const memberCurrentUser = { - ...memberMock, - user: { - ...memberMock.user, - id: 1, - }, - }; - - const createComponentWithDirectMember = (member = {}) => { - createComponent({ - member: { - ...memberMock, - source: { - ...memberMock.source, - id: 1, - }, - ...member, - }, - }); - }; - const createComponentWithInheritedMember = (member = {}) => { - createComponent({ - member: { ...memberMock, ...member }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - test.each` - member | expectedMemberType - ${memberMock} | ${MEMBER_TYPES.user} - ${group} | ${MEMBER_TYPES.group} - ${invite} | ${MEMBER_TYPES.invite} - ${accessRequest} | ${MEMBER_TYPES.accessRequest} - `( - 'sets scoped slot prop `memberType` to $expectedMemberType', - ({ member, expectedMemberType }) => { - createComponent({ member }); - - expect(findWrappedComponent().props('memberType')).toBe(expectedMemberType); - }, - ); - - describe('isDirectMember', () => { - it('returns `true` when member source has same ID as `sourceId`', () => { - createComponentWithDirectMember(); - - expect(findWrappedComponent().props('isDirectMember')).toBe(true); - }); - - it('returns `false` when member is inherited', () => { - createComponentWithInheritedMember(); - - expect(findWrappedComponent().props('isDirectMember')).toBe(false); - }); - - it('returns `true` for linked groups', () => { - createComponent({ - member: group, - }); - - expect(findWrappedComponent().props('isDirectMember')).toBe(true); - }); - }); - - describe('isCurrentUser', () => { - it('returns `true` when `member.user` has the same ID as `currentUserId`', () => { - createComponent({ - member: memberCurrentUser, - }); - - expect(findWrappedComponent().props('isCurrentUser')).toBe(true); - }); - - it('returns `false` when `member.user` does not have the same ID as `currentUserId`', () => { - createComponent({ - member: memberMock, - }); - - expect(findWrappedComponent().props('isCurrentUser')).toBe(false); - }); - }); - - describe('permissions', () => { - describe('canRemove', () => { - describe('for a direct member', () => { - it('returns `true` when `canRemove` is `true`', () => { - createComponentWithDirectMember({ - canRemove: true, - }); - - expect(findWrappedComponent().props('permissions').canRemove).toBe(true); - }); - - it('returns `false` when `canRemove` is `false`', () => { - createComponentWithDirectMember({ - canRemove: false, - }); - - expect(findWrappedComponent().props('permissions').canRemove).toBe(false); - }); - }); - - describe('for an inherited member', () => { - it('returns `false`', () => { - createComponentWithInheritedMember(); - - expect(findWrappedComponent().props('permissions').canRemove).toBe(false); - }); - }); - }); - - describe('canResend', () => { - describe('when member type is `invite`', () => { - it('returns `true` when `canResend` is `true`', () => { - createComponent({ - member: invite, - }); - - expect(findWrappedComponent().props('permissions').canResend).toBe(true); - }); - - it('returns `false` when `canResend` is `false`', () => { - createComponent({ - member: { - ...invite, - invite: { - ...invite, - canResend: false, - }, - }, - }); - - expect(findWrappedComponent().props('permissions').canResend).toBe(false); - }); - }); - - describe('when member type is not `invite`', () => { - it('returns `false`', () => { - createComponent({ member: memberMock }); - - expect(findWrappedComponent().props('permissions').canResend).toBe(false); - }); - }); - }); - - describe('canUpdate', () => { - describe('for a direct member', () => { - it('returns `true` when `canUpdate` is `true`', () => { - createComponentWithDirectMember({ - canUpdate: true, - }); - - expect(findWrappedComponent().props('permissions').canUpdate).toBe(true); - }); - - it('returns `false` when `canUpdate` is `false`', () => { - createComponentWithDirectMember({ - canUpdate: false, - }); - - expect(findWrappedComponent().props('permissions').canUpdate).toBe(false); - }); - - it('returns `false` for current user', () => { - createComponentWithDirectMember(memberCurrentUser); - - expect(findWrappedComponent().props('permissions').canUpdate).toBe(false); - }); - }); - - describe('for an inherited member', () => { - it('returns `false`', () => { - createComponentWithInheritedMember(); - - expect(findWrappedComponent().props('permissions').canUpdate).toBe(false); - }); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/table/members_table_spec.js b/spec/frontend/vue_shared/components/members/table/members_table_spec.js deleted file mode 100644 index e593e88438c..00000000000 --- a/spec/frontend/vue_shared/components/members/table/members_table_spec.js +++ /dev/null @@ -1,212 +0,0 @@ -import { mount, createLocalVue, createWrapper } from '@vue/test-utils'; -import Vuex from 'vuex'; -import { - getByText as getByTextHelper, - getByTestId as getByTestIdHelper, - within, -} from '@testing-library/dom'; -import { GlBadge, GlTable } from '@gitlab/ui'; -import MembersTable from '~/vue_shared/components/members/table/members_table.vue'; -import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue'; -import MemberSource from '~/vue_shared/components/members/table/member_source.vue'; -import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue'; -import CreatedAt from '~/vue_shared/components/members/table/created_at.vue'; -import RoleDropdown from '~/vue_shared/components/members/table/role_dropdown.vue'; -import ExpirationDatepicker from '~/vue_shared/components/members/table/expiration_datepicker.vue'; -import MemberActionButtons from '~/vue_shared/components/members/table/member_action_buttons.vue'; -import * as initUserPopovers from '~/user_popovers'; -import { member as memberMock, invite, accessRequest } from '../mock_data'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('MemberList', () => { - let wrapper; - - const createStore = (state = {}) => { - return new Vuex.Store({ - state: { - members: [], - tableFields: [], - tableAttrs: { - table: { 'data-qa-selector': 'members_list' }, - tr: { 'data-qa-selector': 'member_row' }, - }, - sourceId: 1, - currentUserId: 1, - ...state, - }, - }); - }; - - const createComponent = state => { - wrapper = mount(MembersTable, { - localVue, - store: createStore(state), - stubs: [ - 'member-avatar', - 'member-source', - 'expires-at', - 'created-at', - 'member-action-buttons', - 'role-dropdown', - 'remove-group-link-modal', - 'expiration-datepicker', - ], - }); - }; - - const getByText = (text, options) => - createWrapper(getByTextHelper(wrapper.element, text, options)); - - const getByTestId = (id, options) => - createWrapper(getByTestIdHelper(wrapper.element, id, options)); - - const findTable = () => wrapper.find(GlTable); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('fields', () => { - const directMember = { - ...memberMock, - source: { ...memberMock.source, id: 1 }, - }; - - const memberCanUpdate = { - ...directMember, - canUpdate: true, - }; - - it.each` - field | label | member | expectedComponent - ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar} - ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource} - ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt} - ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt} - ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt} - ${'expires'} | ${'Access expires'} | ${memberMock} | ${ExpiresAt} - ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown} - ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker} - `('renders the $label field', ({ field, label, member, expectedComponent }) => { - createComponent({ - members: [member], - tableFields: [field], - }); - - expect(getByText(label, { selector: '[role="columnheader"]' }).exists()).toBe(true); - - if (expectedComponent) { - expect( - wrapper - .find(`[data-label="${label}"][role="cell"]`) - .find(expectedComponent) - .exists(), - ).toBe(true); - } - }); - - describe('"Actions" field', () => { - it('renders "Actions" field for screen readers', () => { - createComponent({ members: [memberCanUpdate], tableFields: ['actions'] }); - - const actionField = getByTestId('col-actions'); - - expect(actionField.exists()).toBe(true); - expect(actionField.classes('gl-sr-only')).toBe(true); - expect( - wrapper - .find(`[data-label="Actions"][role="cell"]`) - .find(MemberActionButtons) - .exists(), - ).toBe(true); - }); - - describe('when user is not logged in', () => { - it('does not render the "Actions" field', () => { - createComponent({ currentUserId: null, tableFields: ['actions'] }); - - expect(within(wrapper.element).queryByTestId('col-actions')).toBe(null); - }); - }); - - const memberCanRemove = { - ...directMember, - canRemove: true, - }; - - describe.each` - permission | members - ${'canUpdate'} | ${[memberCanUpdate]} - ${'canRemove'} | ${[memberCanRemove]} - ${'canResend'} | ${[invite]} - `('when one of the members has $permission permissions', ({ members }) => { - it('renders the "Actions" field', () => { - createComponent({ members, tableFields: ['actions'] }); - - expect(getByTestId('col-actions').exists()).toBe(true); - }); - }); - - describe.each` - permission | members - ${'canUpdate'} | ${[memberMock]} - ${'canRemove'} | ${[memberMock]} - ${'canResend'} | ${[{ ...invite, invite: { ...invite.invite, canResend: false } }]} - `('when none of the members have $permission permissions', ({ members }) => { - it('does not render the "Actions" field', () => { - createComponent({ members, tableFields: ['actions'] }); - - expect(within(wrapper.element).queryByTestId('col-actions')).toBe(null); - }); - }); - }); - }); - - describe('when `members` is an empty array', () => { - it('displays a "No members found" message', () => { - createComponent(); - - expect(getByText('No members found').exists()).toBe(true); - }); - }); - - describe('when member can not be updated', () => { - it('renders badge in "Max role" field', () => { - createComponent({ members: [memberMock], tableFields: ['maxRole'] }); - - expect( - wrapper - .find(`[data-label="Max role"][role="cell"]`) - .find(GlBadge) - .text(), - ).toBe(memberMock.accessLevel.stringValue); - }); - }); - - it('initializes user popovers when mounted', () => { - const initUserPopoversMock = jest.spyOn(initUserPopovers, 'default'); - - createComponent(); - - expect(initUserPopoversMock).toHaveBeenCalled(); - }); - - it('adds QA selector to table', () => { - createComponent(); - - expect(findTable().attributes('data-qa-selector')).toBe('members_list'); - }); - - it('adds QA selector to table row', () => { - createComponent(); - - expect( - findTable() - .find('tbody tr') - .attributes('data-qa-selector'), - ).toBe('member_row'); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js b/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js deleted file mode 100644 index 55ec7000693..00000000000 --- a/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js +++ /dev/null @@ -1,151 +0,0 @@ -import { mount, createWrapper, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import { nextTick } from 'vue'; -import { within } from '@testing-library/dom'; -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import waitForPromises from 'helpers/wait_for_promises'; -import RoleDropdown from '~/vue_shared/components/members/table/role_dropdown.vue'; -import { member } from '../mock_data'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('RoleDropdown', () => { - let wrapper; - let actions; - const $toast = { - show: jest.fn(), - }; - - const createStore = () => { - actions = { - updateMemberRole: jest.fn(() => Promise.resolve()), - }; - - return new Vuex.Store({ actions }); - }; - - const createComponent = (propsData = {}) => { - wrapper = mount(RoleDropdown, { - propsData: { - member, - permissions: {}, - ...propsData, - }, - localVue, - store: createStore(), - mocks: { - $toast, - }, - }); - }; - - const getDropdownMenu = () => within(wrapper.element).getByRole('menu'); - const getByTextInDropdownMenu = (text, options = {}) => - createWrapper(within(getDropdownMenu()).getByText(text, options)); - const getDropdownItemByText = text => - createWrapper( - within(getDropdownMenu()) - .getByText(text, { selector: '[role="menuitem"] p' }) - .closest('[role="menuitem"]'), - ); - const getCheckedDropdownItem = () => - wrapper - .findAll(GlDropdownItem) - .wrappers.find(dropdownItemWrapper => dropdownItemWrapper.props('isChecked')); - - const findDropdownToggle = () => wrapper.find('button[aria-haspopup="true"]'); - const findDropdown = () => wrapper.find(GlDropdown); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('when dropdown is open', () => { - beforeEach(done => { - createComponent(); - - findDropdownToggle().trigger('click'); - wrapper.vm.$root.$on('bv::dropdown::shown', () => { - done(); - }); - }); - - it('renders all valid roles', () => { - Object.keys(member.validRoles).forEach(role => { - expect(getDropdownItemByText(role).exists()).toBe(true); - }); - }); - - it('renders dropdown header', () => { - expect(getByTextInDropdownMenu('Change permissions').exists()).toBe(true); - }); - - it('sets dropdown toggle and checks selected role', () => { - expect(findDropdownToggle().text()).toBe('Owner'); - expect(getCheckedDropdownItem().text()).toBe('Owner'); - }); - - describe('when dropdown item is selected', () => { - it('does nothing if the item selected was already selected', () => { - getDropdownItemByText('Owner').trigger('click'); - - expect(actions.updateMemberRole).not.toHaveBeenCalled(); - }); - - it('calls `updateMemberRole` Vuex action', () => { - getDropdownItemByText('Developer').trigger('click'); - - expect(actions.updateMemberRole).toHaveBeenCalledWith(expect.any(Object), { - memberId: member.id, - accessLevel: { integerValue: 30, stringValue: 'Developer' }, - }); - }); - - it('displays toast when successful', async () => { - getDropdownItemByText('Developer').trigger('click'); - - await waitForPromises(); - - expect($toast.show).toHaveBeenCalledWith('Role updated successfully.'); - }); - - it('disables dropdown while waiting for `updateMemberRole` to resolve', async () => { - getDropdownItemByText('Developer').trigger('click'); - - await nextTick(); - - expect(findDropdown().props('disabled')).toBe(true); - - await waitForPromises(); - - expect(findDropdown().props('disabled')).toBe(false); - }); - }); - }); - - it("sets initial dropdown toggle value to member's role", () => { - createComponent(); - - expect(findDropdownToggle().text()).toBe('Owner'); - }); - - it('sets the dropdown alignment to right on mobile', async () => { - jest.spyOn(bp, 'isDesktop').mockReturnValue(false); - createComponent(); - - await nextTick(); - - expect(findDropdown().attributes('right')).toBe('true'); - }); - - it('sets the dropdown alignment to left on desktop', async () => { - jest.spyOn(bp, 'isDesktop').mockReturnValue(true); - createComponent(); - - await nextTick(); - - expect(findDropdown().attributes('right')).toBeUndefined(); - }); -}); diff --git a/spec/frontend/vue_shared/components/members/utils_spec.js b/spec/frontend/vue_shared/components/members/utils_spec.js deleted file mode 100644 index 3f2b2097133..00000000000 --- a/spec/frontend/vue_shared/components/members/utils_spec.js +++ /dev/null @@ -1,122 +0,0 @@ -import { - generateBadges, - isGroup, - isDirectMember, - isCurrentUser, - canRemove, - canResend, - canUpdate, - canOverride, -} from '~/vue_shared/components/members/utils'; -import { member as memberMock, group, invite } from './mock_data'; - -const DIRECT_MEMBER_ID = 178; -const INHERITED_MEMBER_ID = 179; -const IS_CURRENT_USER_ID = 123; -const IS_NOT_CURRENT_USER_ID = 124; - -describe('Members Utils', () => { - describe('generateBadges', () => { - it('has correct properties for each badge', () => { - const badges = generateBadges(memberMock, true); - - badges.forEach(badge => { - expect(badge).toEqual( - expect.objectContaining({ - show: expect.any(Boolean), - text: expect.any(String), - variant: expect.stringMatching(/muted|neutral|info|success|danger|warning/), - }), - ); - }); - }); - - it.each` - member | expected - ${memberMock} | ${{ show: true, text: "It's you", variant: 'success' }} - ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${{ show: true, text: 'Blocked', variant: 'danger' }} - ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${{ show: true, text: '2FA', variant: 'info' }} - `('returns expected output for "$expected.text" badge', ({ member, expected }) => { - expect(generateBadges(member, true)).toContainEqual(expect.objectContaining(expected)); - }); - }); - - describe('isGroup', () => { - test.each` - member | expected - ${group} | ${true} - ${memberMock} | ${false} - `('returns $expected', ({ member, expected }) => { - expect(isGroup(member)).toBe(expected); - }); - }); - - describe('isDirectMember', () => { - test.each` - sourceId | expected - ${DIRECT_MEMBER_ID} | ${true} - ${INHERITED_MEMBER_ID} | ${false} - `('returns $expected', ({ sourceId, expected }) => { - expect(isDirectMember(memberMock, sourceId)).toBe(expected); - }); - }); - - describe('isCurrentUser', () => { - test.each` - currentUserId | expected - ${IS_CURRENT_USER_ID} | ${true} - ${IS_NOT_CURRENT_USER_ID} | ${false} - `('returns $expected', ({ currentUserId, expected }) => { - expect(isCurrentUser(memberMock, currentUserId)).toBe(expected); - }); - }); - - describe('canRemove', () => { - const memberCanRemove = { - ...memberMock, - canRemove: true, - }; - - test.each` - member | sourceId | expected - ${memberCanRemove} | ${DIRECT_MEMBER_ID} | ${true} - ${memberCanRemove} | ${INHERITED_MEMBER_ID} | ${false} - ${memberMock} | ${INHERITED_MEMBER_ID} | ${false} - `('returns $expected', ({ member, sourceId, expected }) => { - expect(canRemove(member, sourceId)).toBe(expected); - }); - }); - - describe('canResend', () => { - test.each` - member | expected - ${invite} | ${true} - ${{ ...invite, invite: { ...invite.invite, canResend: false } }} | ${false} - `('returns $expected', ({ member, sourceId, expected }) => { - expect(canResend(member, sourceId)).toBe(expected); - }); - }); - - describe('canUpdate', () => { - const memberCanUpdate = { - ...memberMock, - canUpdate: true, - }; - - test.each` - member | currentUserId | sourceId | expected - ${memberCanUpdate} | ${IS_NOT_CURRENT_USER_ID} | ${DIRECT_MEMBER_ID} | ${true} - ${memberCanUpdate} | ${IS_CURRENT_USER_ID} | ${DIRECT_MEMBER_ID} | ${false} - ${memberCanUpdate} | ${IS_CURRENT_USER_ID} | ${INHERITED_MEMBER_ID} | ${false} - ${memberMock} | ${IS_NOT_CURRENT_USER_ID} | ${DIRECT_MEMBER_ID} | ${false} - `('returns $expected', ({ member, currentUserId, sourceId, expected }) => { - expect(canUpdate(member, currentUserId, sourceId)).toBe(expected); - }); - }); - - describe('canOverride', () => { - it('returns `false`', () => { - expect(canOverride(memberMock)).toBe(false); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap index ecea151fc8a..da49778f216 100644 --- a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap +++ b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap @@ -47,6 +47,7 @@ exports[`Package code instruction single line to match the default snapshot 1`] <!----> <svg + aria-hidden="true" class="gl-button-icon gl-icon s16" data-testid="copy-to-clipboard-icon" > diff --git a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap index 3990248d021..623f7d083c5 100644 --- a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap +++ b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap @@ -10,6 +10,9 @@ exports[`Resizable Skeleton Loader default setup renders the bars, labels, and g version="1.1" viewBox="0 0 400 130" > + <title> + Loading + </title> <rect clip-path="url(#null-idClip)" height="130" @@ -226,6 +229,9 @@ exports[`Resizable Skeleton Loader with custom settings renders the correct posi version="1.1" viewBox="0 0 400 130" > + <title> + Loading + </title> <rect clip-path="url(#-idClip)" height="130" diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js index d50cf2915e8..cd1157a1c2e 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { mockEditorApi } from '@toast-ui/vue-editor'; import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue'; import InsertVideoModal from '~/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue'; @@ -114,10 +115,17 @@ describe('Rich Content Editor', () => { }); describe('when editor is loaded', () => { + const formattedMarkdown = 'formatted markdown'; + beforeEach(() => { + mockEditorApi.getMarkdown.mockReturnValueOnce(formattedMarkdown); buildWrapper(); }); + afterEach(() => { + mockEditorApi.getMarkdown.mockReset(); + }); + it('adds the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { expect(addCustomEventListener).toHaveBeenCalledWith( wrapper.vm.editorApi, @@ -137,6 +145,11 @@ describe('Rich Content Editor', () => { it('registers HTML to markdown renderer', () => { expect(registerHTMLToMarkdownRenderer).toHaveBeenCalledWith(wrapper.vm.editorApi); }); + + it('emits load event with the markdown formatted by Toast UI', () => { + expect(mockEditorApi.getMarkdown).toHaveBeenCalled(); + expect(wrapper.emitted('load')[0]).toEqual([{ formattedMarkdown }]); + }); }); describe('when editor is destroyed', () => { diff --git a/spec/frontend/vue_shared/components/security_reports/__snapshots__/security_summary_spec.js.snap b/spec/frontend/vue_shared/components/security_reports/__snapshots__/security_summary_spec.js.snap new file mode 100644 index 00000000000..1e08394dd56 --- /dev/null +++ b/spec/frontend/vue_shared/components/security_reports/__snapshots__/security_summary_spec.js.snap @@ -0,0 +1,144 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SecuritySummary component given the message {"countMessage": "%{criticalStart}0 Critical%{criticalEnd} %{highStart}1 High%{highEnd} and %{otherStart}0 Others%{otherEnd}", "critical": 0, "high": 1, "message": "Security scanning detected %{totalStart}1%{totalEnd} potential vulnerability", "other": 0, "status": "", "total": 1} interpolates correctly 1`] = ` +<span> + Security scanning detected + <strong> + 1 + </strong> + potential vulnerability + <span + class="gl-font-sm" + > + <span> + <span + class="gl-pl-4" + > + + 0 Critical + + </span> + </span> + + <span> + <strong + class="text-danger-600 gl-px-2" + > + + 1 High + + </strong> + </span> + and + <span> + <span + class="gl-px-2" + > + + 0 Others + + </span> + </span> + </span> +</span> +`; + +exports[`SecuritySummary component given the message {"countMessage": "%{criticalStart}1 Critical%{criticalEnd} %{highStart}0 High%{highEnd} and %{otherStart}0 Others%{otherEnd}", "critical": 1, "high": 0, "message": "Security scanning detected %{totalStart}1%{totalEnd} potential vulnerability", "other": 0, "status": "", "total": 1} interpolates correctly 1`] = ` +<span> + Security scanning detected + <strong> + 1 + </strong> + potential vulnerability + <span + class="gl-font-sm" + > + <span> + <strong + class="text-danger-800 gl-pl-4" + > + + 1 Critical + + </strong> + </span> + + <span> + <span + class="gl-px-2" + > + + 0 High + + </span> + </span> + and + <span> + <span + class="gl-px-2" + > + + 0 Others + + </span> + </span> + </span> +</span> +`; + +exports[`SecuritySummary component given the message {"countMessage": "%{criticalStart}1 Critical%{criticalEnd} %{highStart}2 High%{highEnd} and %{otherStart}0 Others%{otherEnd}", "critical": 1, "high": 2, "message": "Security scanning detected %{totalStart}3%{totalEnd} potential vulnerabilities", "other": 0, "status": "", "total": 3} interpolates correctly 1`] = ` +<span> + Security scanning detected + <strong> + 3 + </strong> + potential vulnerabilities + <span + class="gl-font-sm" + > + <span> + <strong + class="text-danger-800 gl-pl-4" + > + + 1 Critical + + </strong> + </span> + + <span> + <strong + class="text-danger-600 gl-px-2" + > + + 2 High + + </strong> + </span> + and + <span> + <span + class="gl-px-2" + > + + 0 Others + + </span> + </span> + </span> +</span> +`; + +exports[`SecuritySummary component given the message {"message": ""} interpolates correctly 1`] = ` +<span> + + <!----> +</span> +`; + +exports[`SecuritySummary component given the message {"message": "foo"} interpolates correctly 1`] = ` +<span> + foo + <!----> +</span> +`; diff --git a/spec/frontend/vue_shared/components/security_reports/help_icon_spec.js b/spec/frontend/vue_shared/components/security_reports/help_icon_spec.js new file mode 100644 index 00000000000..60203493cbd --- /dev/null +++ b/spec/frontend/vue_shared/components/security_reports/help_icon_spec.js @@ -0,0 +1,68 @@ +import { GlLink, GlPopover } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import HelpIcon from '~/vue_shared/security_reports/components/help_icon.vue'; + +const helpPath = '/docs'; +const discoverProjectSecurityPath = '/discoverProjectSecurityPath'; + +describe('HelpIcon component', () => { + let wrapper; + + const createWrapper = props => { + wrapper = shallowMount(HelpIcon, { + propsData: { + helpPath, + ...props, + }, + }); + }; + + const findLink = () => wrapper.find(GlLink); + const findPopover = () => wrapper.find(GlPopover); + const findPopoverTarget = () => wrapper.find({ ref: 'discoverProjectSecurity' }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('given a help path only', () => { + beforeEach(() => { + createWrapper(); + }); + + it('does not render a popover', () => { + expect(findPopover().exists()).toBe(false); + }); + + it('renders a help link', () => { + expect(findLink().attributes()).toMatchObject({ + href: helpPath, + target: '_blank', + }); + }); + }); + + describe('given a help path and discover project security path', () => { + beforeEach(() => { + createWrapper({ discoverProjectSecurityPath }); + }); + + it('renders a popover', () => { + const popover = findPopover(); + expect(popover.props('target')()).toBe(findPopoverTarget().element); + expect(popover.attributes()).toMatchObject({ + title: HelpIcon.i18n.upgradeToManageVulnerabilities, + triggers: 'click blur', + }); + expect(popover.text()).toContain(HelpIcon.i18n.upgradeToInteract); + }); + + it('renders a link to the discover path', () => { + expect(findLink().attributes()).toMatchObject({ + href: discoverProjectSecurityPath, + target: '_blank', + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/security_reports/security_summary_spec.js b/spec/frontend/vue_shared/components/security_reports/security_summary_spec.js new file mode 100644 index 00000000000..e57152c3cbf --- /dev/null +++ b/spec/frontend/vue_shared/components/security_reports/security_summary_spec.js @@ -0,0 +1,38 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import SecuritySummary from '~/vue_shared/security_reports/components/security_summary.vue'; +import { groupedTextBuilder } from '~/vue_shared/security_reports/store/utils'; + +describe('SecuritySummary component', () => { + let wrapper; + + const createWrapper = message => { + wrapper = shallowMount(SecuritySummary, { + propsData: { message }, + stubs: { + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe.each([ + { message: '' }, + { message: 'foo' }, + groupedTextBuilder({ reportType: 'Security scanning', critical: 1, high: 0, total: 1 }), + groupedTextBuilder({ reportType: 'Security scanning', critical: 0, high: 1, total: 1 }), + groupedTextBuilder({ reportType: 'Security scanning', critical: 1, high: 2, total: 3 }), + ])('given the message %p', message => { + beforeEach(() => { + createWrapper(message); + }); + + it('interpolates correctly', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js index 9db86fa775f..596cb22fca5 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js @@ -33,8 +33,8 @@ describe('BaseComponent', () => { expect(vm.hiddenInputName).toBe('issue[label_names][]'); }); - it('returns correct string when showCreate prop is `false`', () => { - wrapper.setProps({ showCreate: false }); + it('returns correct string when showCreate prop is `false`', async () => { + await wrapper.setProps({ showCreate: false }); expect(vm.hiddenInputName).toBe('label_id[]'); }); @@ -45,8 +45,8 @@ describe('BaseComponent', () => { expect(vm.createLabelTitle).toBe('Create project label'); }); - it('return `Create group label` when `isProject` prop is false', () => { - wrapper.setProps({ isProject: false }); + it('return `Create group label` when `isProject` prop is false', async () => { + await wrapper.setProps({ isProject: false }); expect(vm.createLabelTitle).toBe('Create group label'); }); @@ -57,8 +57,8 @@ describe('BaseComponent', () => { expect(vm.manageLabelsTitle).toBe('Manage project labels'); }); - it('return `Manage group labels` when `isProject` prop is false', () => { - wrapper.setProps({ isProject: false }); + it('return `Manage group labels` when `isProject` prop is false', async () => { + await wrapper.setProps({ isProject: false }); expect(vm.manageLabelsTitle).toBe('Manage group labels'); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js index 8c17a974b39..1206450bbeb 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js @@ -20,22 +20,24 @@ jest.mock('~/lib/utils/common_utils', () => ({ const localVue = createLocalVue(); localVue.use(Vuex); -const createComponent = (config = mockConfig, slots = {}) => - shallowMount(LabelsSelectRoot, { - localVue, - slots, - store: new Vuex.Store(labelsSelectModule()), - propsData: config, - stubs: { - 'dropdown-contents': DropdownContents, - }, - }); - describe('LabelsSelectRoot', () => { let wrapper; + let store; + + const createComponent = (config = mockConfig, slots = {}) => { + wrapper = shallowMount(LabelsSelectRoot, { + localVue, + slots, + store, + propsData: config, + stubs: { + 'dropdown-contents': DropdownContents, + }, + }); + }; beforeEach(() => { - wrapper = createComponent(); + store = new Vuex.Store(labelsSelectModule()); }); afterEach(() => { @@ -45,6 +47,7 @@ describe('LabelsSelectRoot', () => { describe('methods', () => { describe('handleVuexActionDispatch', () => { it('calls `handleDropdownClose` when params `action.type` is `toggleDropdownContents` and state has `showDropdownButton` & `showDropdownContents` props `false`', () => { + createComponent(); jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation(); wrapper.vm.handleVuexActionDispatch( @@ -67,7 +70,7 @@ describe('LabelsSelectRoot', () => { }); it('calls `handleDropdownClose` with state.labels filterd using `set` prop when dropdown variant is `embedded`', () => { - wrapper = createComponent({ + createComponent({ ...mockConfig, variant: 'embedded', }); @@ -95,6 +98,10 @@ describe('LabelsSelectRoot', () => { }); describe('handleDropdownClose', () => { + beforeEach(() => { + createComponent(); + }); + it('emits `updateSelectedLabels` & `onDropdownClose` events on component when provided `labels` param is not empty', () => { wrapper.vm.handleDropdownClose([{ id: 1 }, { id: 2 }]); @@ -112,6 +119,7 @@ describe('LabelsSelectRoot', () => { describe('handleCollapsedValueClick', () => { it('emits `toggleCollapse` event on component', () => { + createComponent(); wrapper.vm.handleCollapsedValueClick(); expect(wrapper.emitted().toggleCollapse).toBeTruthy(); @@ -121,6 +129,7 @@ describe('LabelsSelectRoot', () => { describe('template', () => { it('renders component with classes `labels-select-wrapper position-relative`', () => { + createComponent(); expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative'); }); @@ -131,7 +140,7 @@ describe('LabelsSelectRoot', () => { `( 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"', ({ variant, cssClass }) => { - wrapper = createComponent({ + createComponent({ ...mockConfig, variant, }); @@ -142,57 +151,58 @@ describe('LabelsSelectRoot', () => { }, ); - it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', () => { + it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', async () => { + createComponent(); + await wrapper.vm.$nextTick; expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true); }); - it('renders `dropdown-title` component', () => { + it('renders `dropdown-title` component', async () => { + createComponent(); + await wrapper.vm.$nextTick; expect(wrapper.find(DropdownTitle).exists()).toBe(true); }); - it('renders `dropdown-value` component', () => { - const wrapperDropdownValue = createComponent(mockConfig, { + it('renders `dropdown-value` component', async () => { + createComponent(mockConfig, { default: 'None', }); + await wrapper.vm.$nextTick; - return wrapperDropdownValue.vm.$nextTick(() => { - const valueComp = wrapperDropdownValue.find(DropdownValue); + const valueComp = wrapper.find(DropdownValue); - expect(valueComp.exists()).toBe(true); - expect(valueComp.text()).toBe('None'); - - wrapperDropdownValue.destroy(); - }); + expect(valueComp.exists()).toBe(true); + expect(valueComp.text()).toBe('None'); }); - it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', () => { + it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', async () => { + createComponent(); wrapper.vm.$store.dispatch('toggleDropdownButton'); - + await wrapper.vm.$nextTick; expect(wrapper.find(DropdownButton).exists()).toBe(true); }); - it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', () => { + it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', async () => { + createComponent(); wrapper.vm.$store.dispatch('toggleDropdownContents'); - - return wrapper.vm.$nextTick(() => { - expect(wrapper.find(DropdownContents).exists()).toBe(true); - }); + await wrapper.vm.$nextTick; + expect(wrapper.find(DropdownContents).exists()).toBe(true); }); describe('sets content direction based on viewport', () => { - it('does not set direction when `state.variant` is not "embedded"', () => { - wrapper.vm.$store.dispatch('toggleDropdownContents'); + it('does not set direction when `state.variant` is not "embedded"', async () => { + createComponent(); + wrapper.vm.$store.dispatch('toggleDropdownContents'); wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state); + await wrapper.vm.$nextTick; - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false); - }); + expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false); }); describe('when `state.variant` is "embedded"', () => { beforeEach(() => { - wrapper = createComponent({ ...mockConfig, variant: 'embedded' }); + createComponent({ ...mockConfig, variant: 'embedded' }); wrapper.vm.$store.dispatch('toggleDropdownContents'); }); @@ -216,4 +226,22 @@ describe('LabelsSelectRoot', () => { }); }); }); + + it('calls toggleDropdownContents action when isEditing prop is changing to true', async () => { + createComponent(); + + jest.spyOn(store, 'dispatch').mockResolvedValue(); + await wrapper.setProps({ isEditing: true }); + + expect(store.dispatch).toHaveBeenCalledWith('toggleDropdownContents'); + }); + + it('does not call toggleDropdownContents action when isEditing prop is changing to false', async () => { + createComponent(); + + jest.spyOn(store, 'dispatch').mockResolvedValue(); + await wrapper.setProps({ isEditing: false }); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); }); diff --git a/spec/frontend/vue_shared/components/toggle_button_spec.js b/spec/frontend/vue_shared/components/toggle_button_spec.js index f58647ff12b..2822b1999bc 100644 --- a/spec/frontend/vue_shared/components/toggle_button_spec.js +++ b/spec/frontend/vue_shared/components/toggle_button_spec.js @@ -1,101 +1,96 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import toggleButton from '~/vue_shared/components/toggle_button.vue'; +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import ToggleButton from '~/vue_shared/components/toggle_button.vue'; -describe('Toggle Button', () => { - let vm; - let Component; +describe('Toggle Button component', () => { + let wrapper; - beforeEach(() => { - Component = Vue.extend(toggleButton); - }); + function createComponent(propsData = {}) { + wrapper = shallowMount(ToggleButton, { + propsData, + }); + } + + const findInput = () => wrapper.find('input'); + const findButton = () => wrapper.find('button'); + const findToggleIcon = () => wrapper.find(GlIcon); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + wrapper = null; }); - describe('render output', () => { - beforeEach(() => { - vm = mountComponent(Component, { - value: true, - name: 'foo', - }); - }); - - it('renders input with provided name', () => { - expect(vm.$el.querySelector('input').getAttribute('name')).toEqual('foo'); + it('renders input with provided name', () => { + createComponent({ + name: 'foo', }); - it('renders input with provided value', () => { - expect(vm.$el.querySelector('input').getAttribute('value')).toEqual('true'); - }); - - it('renders input status icon', () => { - expect(vm.$el.querySelectorAll('span.toggle-icon').length).toEqual(1); - expect(vm.$el.querySelectorAll('svg.s18').length).toEqual(1); - }); + expect(findInput().attributes('name')).toBe('foo'); }); - describe('is-checked', () => { + describe.each` + value | iconName + ${true} | ${'status_success_borderless'} + ${false} | ${'status_failed_borderless'} + `('when `value` prop is `$value`', ({ value, iconName }) => { beforeEach(() => { - vm = mountComponent(Component, { - value: true, + createComponent({ + value, + name: 'foo', }); - - jest.spyOn(vm, '$emit').mockImplementation(() => {}); }); - it('renders is checked class', () => { - expect(vm.$el.querySelector('button').classList.contains('is-checked')).toEqual(true); + it('renders input with correct value attribute', () => { + expect(findInput().attributes('value')).toBe(`${value}`); }); - it('sets aria-label representing toggle state', () => { - vm.value = true; - - expect(vm.ariaLabel).toEqual('Toggle Status: ON'); - - vm.value = false; - - expect(vm.ariaLabel).toEqual('Toggle Status: OFF'); + it('renders correct icon', () => { + const icon = findToggleIcon(); + expect(icon.isVisible()).toBe(true); + expect(icon.props('name')).toBe(iconName); + expect(findButton().classes('is-checked')).toBe(value); }); - it('emits change event when clicked', () => { - vm.$el.querySelector('button').click(); + describe('when clicked', () => { + it('emits `change` event with correct event', async () => { + findButton().trigger('click'); + await wrapper.vm.$nextTick(); - expect(vm.$emit).toHaveBeenCalledWith('change', false); + expect(wrapper.emitted('change')).toStrictEqual([[!value]]); + }); }); }); - describe('is-disabled', () => { + describe('when `disabledInput` prop is `true`', () => { beforeEach(() => { - vm = mountComponent(Component, { + createComponent({ value: true, disabledInput: true, }); - jest.spyOn(vm, '$emit').mockImplementation(() => {}); }); it('renders disabled button', () => { - expect(vm.$el.querySelector('button').classList.contains('is-disabled')).toEqual(true); + expect(findButton().classes()).toContain('is-disabled'); }); - it('does not emit change event when clicked', () => { - vm.$el.querySelector('button').click(); + it('does not emit change event when clicked', async () => { + findButton().trigger('click'); + await wrapper.vm.$nextTick(); - expect(vm.$emit).not.toHaveBeenCalled(); + expect(wrapper.emitted('change')).toBeFalsy(); }); }); - describe('is-loading', () => { + describe('when `isLoading` prop is `true`', () => { beforeEach(() => { - vm = mountComponent(Component, { + createComponent({ value: true, isLoading: true, }); }); it('renders loading class', () => { - expect(vm.$el.querySelector('button').classList.contains('is-loading')).toEqual(true); + expect(findButton().classes()).toContain('is-loading'); }); }); }); diff --git a/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js new file mode 100644 index 00000000000..175abf5aae0 --- /dev/null +++ b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js @@ -0,0 +1,239 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import { hasHorizontalOverflow } from '~/lib/utils/dom_utils'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; + +const DUMMY_TEXT = 'lorem-ipsum-dolar-sit-amit-consectur-adipiscing-elit-sed-do'; + +const createChildElement = () => `<a href="#">${DUMMY_TEXT}</a>`; + +jest.mock('~/lib/utils/dom_utils', () => ({ + hasHorizontalOverflow: jest.fn(() => { + throw new Error('this needs to be mocked'); + }), +})); + +describe('TooltipOnTruncate component', () => { + let wrapper; + let parent; + + const createComponent = ({ propsData, ...options } = {}) => { + wrapper = shallowMount(TooltipOnTruncate, { + attachToDocument: true, + propsData: { + ...propsData, + }, + ...options, + }); + }; + + const createWrappedComponent = ({ propsData, ...options }) => { + // set a parent around the tested component + parent = mount( + { + props: { + title: { default: '' }, + }, + template: ` + <TooltipOnTruncate :title="title" truncate-target="child"> + <div>{{title}}</div> + </TooltipOnTruncate> + `, + components: { + TooltipOnTruncate, + }, + }, + { + propsData: { ...propsData }, + attachToDocument: true, + ...options, + }, + ); + + wrapper = parent.find(TooltipOnTruncate); + }; + + const hasTooltip = () => wrapper.classes('js-show-tooltip'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('with default target', () => { + it('renders tooltip if truncated', () => { + hasHorizontalOverflow.mockReturnValueOnce(true); + createComponent({ + propsData: { + title: DUMMY_TEXT, + }, + slots: { + default: [DUMMY_TEXT], + }, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element); + expect(hasTooltip()).toBe(true); + expect(wrapper.attributes('data-original-title')).toEqual(DUMMY_TEXT); + expect(wrapper.attributes('data-placement')).toEqual('top'); + }); + }); + + it('does not render tooltip if normal', () => { + hasHorizontalOverflow.mockReturnValueOnce(false); + createComponent({ + propsData: { + title: DUMMY_TEXT, + }, + slots: { + default: [DUMMY_TEXT], + }, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element); + expect(hasTooltip()).toBe(false); + }); + }); + }); + + describe('with child target', () => { + it('renders tooltip if truncated', () => { + hasHorizontalOverflow.mockReturnValueOnce(true); + createComponent({ + propsData: { + title: DUMMY_TEXT, + truncateTarget: 'child', + }, + slots: { + default: createChildElement(), + }, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[0]); + expect(hasTooltip()).toBe(true); + }); + }); + + it('does not render tooltip if normal', () => { + hasHorizontalOverflow.mockReturnValueOnce(false); + createComponent({ + propsData: { + truncateTarget: 'child', + }, + slots: { + default: createChildElement(), + }, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[0]); + expect(hasTooltip()).toBe(false); + }); + }); + }); + + describe('with fn target', () => { + it('renders tooltip if truncated', () => { + hasHorizontalOverflow.mockReturnValueOnce(true); + createComponent({ + propsData: { + title: DUMMY_TEXT, + truncateTarget: el => el.childNodes[1], + }, + slots: { + default: [createChildElement(), createChildElement()], + }, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[1]); + expect(hasTooltip()).toBe(true); + }); + }); + }); + + describe('placement', () => { + it('sets data-placement when tooltip is rendered', () => { + const placement = 'bottom'; + + hasHorizontalOverflow.mockReturnValueOnce(true); + createComponent({ + propsData: { + placement, + }, + slots: { + default: DUMMY_TEXT, + }, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(hasTooltip()).toBe(true); + expect(wrapper.attributes('data-placement')).toEqual(placement); + }); + }); + }); + + describe('updates when title and slot content changes', () => { + describe('is initialized with a long text', () => { + beforeEach(() => { + hasHorizontalOverflow.mockReturnValueOnce(true); + createWrappedComponent({ + propsData: { title: DUMMY_TEXT }, + }); + return parent.vm.$nextTick(); + }); + + it('renders tooltip', () => { + expect(hasTooltip()).toBe(true); + expect(wrapper.attributes('data-original-title')).toEqual(DUMMY_TEXT); + expect(wrapper.attributes('data-placement')).toEqual('top'); + }); + + it('does not render tooltip after updated to a short text', () => { + hasHorizontalOverflow.mockReturnValueOnce(false); + parent.setProps({ + title: 'new-text', + }); + + return wrapper.vm + .$nextTick() + .then(() => wrapper.vm.$nextTick()) // wait 2 times to get an updated slot + .then(() => { + expect(hasTooltip()).toBe(false); + }); + }); + }); + + describe('is initialized with a short text', () => { + beforeEach(() => { + hasHorizontalOverflow.mockReturnValueOnce(false); + createWrappedComponent({ + propsData: { title: DUMMY_TEXT }, + }); + return wrapper.vm.$nextTick(); + }); + + it('does not render tooltip', () => { + expect(hasTooltip()).toBe(false); + }); + + it('renders tooltip after text is updated', () => { + hasHorizontalOverflow.mockReturnValueOnce(true); + const newText = 'new-text'; + parent.setProps({ + title: newText, + }); + + return wrapper.vm + .$nextTick() + .then(() => wrapper.vm.$nextTick()) // wait 2 times to get an updated slot + .then(() => { + expect(hasTooltip()).toBe(true); + expect(wrapper.attributes('data-original-title')).toEqual(newText); + expect(wrapper.attributes('data-placement')).toEqual('top'); + }); + }); + }); + }); +}); |