diff options
Diffstat (limited to 'spec/frontend/issuable_list')
4 files changed, 522 insertions, 10 deletions
diff --git a/spec/frontend/issuable_list/components/issuable_bulk_edit_sidebar_spec.js b/spec/frontend/issuable_list/components/issuable_bulk_edit_sidebar_spec.js new file mode 100644 index 00000000000..52a238eac7c --- /dev/null +++ b/spec/frontend/issuable_list/components/issuable_bulk_edit_sidebar_spec.js @@ -0,0 +1,97 @@ +import { shallowMount } from '@vue/test-utils'; + +import IssuableBulkEditSidebar from '~/issuable_list/components/issuable_bulk_edit_sidebar.vue'; + +const createComponent = ({ expanded = true } = {}) => + shallowMount(IssuableBulkEditSidebar, { + propsData: { + expanded, + }, + slots: { + 'bulk-edit-actions': ` + <button class="js-edit-issuables">Edit issuables</button> + `, + 'sidebar-items': ` + <button class="js-sidebar-dropdown">Labels</button> + `, + }, + }); + +describe('IssuableBulkEditSidebar', () => { + let wrapper; + + beforeEach(() => { + setFixtures('<div class="layout-page right-sidebar-collapsed"></div>'); + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('watch', () => { + describe('expanded', () => { + it.each` + expanded | layoutPageClass + ${true} | ${'right-sidebar-expanded'} + ${false} | ${'right-sidebar-collapsed'} + `( + 'sets class "$layoutPageClass" on element `.layout-page` when expanded prop is $expanded', + async ({ expanded, layoutPageClass }) => { + const wrappeCustom = createComponent({ + expanded: !expanded, + }); + + // We need to manually flip the value of `expanded` for + // watcher to trigger. + wrappeCustom.setProps({ + expanded, + }); + + await wrappeCustom.vm.$nextTick(); + + expect(document.querySelector('.layout-page').classList.contains(layoutPageClass)).toBe( + true, + ); + + wrappeCustom.destroy(); + }, + ); + }); + }); + + describe('template', () => { + it.each` + expanded | layoutPageClass + ${true} | ${'right-sidebar-expanded'} + ${false} | ${'right-sidebar-collapsed'} + `( + 'renders component container with class "$layoutPageClass" when expanded prop is $expanded', + async ({ expanded, layoutPageClass }) => { + const wrappeCustom = createComponent({ + expanded: !expanded, + }); + + // We need to manually flip the value of `expanded` for + // watcher to trigger. + wrappeCustom.setProps({ + expanded, + }); + + await wrappeCustom.vm.$nextTick(); + + expect(wrappeCustom.classes()).toContain(layoutPageClass); + + wrappeCustom.destroy(); + }, + ); + + it('renders contents for slot `bulk-edit-actions`', () => { + expect(wrapper.find('button.js-edit-issuables').exists()).toBe(true); + }); + + it('renders contents for slot `sidebar-items`', () => { + expect(wrapper.find('button.js-sidebar-dropdown').exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/issuable_list/components/issuable_item_spec.js b/spec/frontend/issuable_list/components/issuable_item_spec.js index a96a4e15e6c..3a9a0d3fd59 100644 --- a/spec/frontend/issuable_list/components/issuable_item_spec.js +++ b/spec/frontend/issuable_list/components/issuable_item_spec.js @@ -1,29 +1,37 @@ import { shallowMount } from '@vue/test-utils'; -import { GlLink, GlLabel } from '@gitlab/ui'; +import { GlLink, GlLabel, GlIcon, GlFormCheckbox } from '@gitlab/ui'; import IssuableItem from '~/issuable_list/components/issuable_item.vue'; +import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; import { mockIssuable, mockRegularLabel, mockScopedLabel } from '../mock_data'; -const createComponent = ({ issuableSymbol = '#', issuable = mockIssuable } = {}) => +const createComponent = ({ issuableSymbol = '#', issuable = mockIssuable, slots = {} } = {}) => shallowMount(IssuableItem, { propsData: { issuableSymbol, issuable, + enableLabelPermalinks: true, + showDiscussions: true, + showCheckbox: false, }, + slots, }); describe('IssuableItem', () => { const mockLabels = mockIssuable.labels.nodes; const mockAuthor = mockIssuable.author; + const originalUrl = gon.gitlab_url; let wrapper; beforeEach(() => { + gon.gitlab_url = 'http://0.0.0.0:3000'; wrapper = createComponent(); }); afterEach(() => { wrapper.destroy(); + gon.gitlab_url = originalUrl; }); describe('computed', () => { @@ -38,8 +46,8 @@ describe('IssuableItem', () => { authorId | returnValue ${1} | ${1} ${'1'} | ${1} - ${'gid://gitlab/User/1'} | ${'1'} - ${'foo'} | ${''} + ${'gid://gitlab/User/1'} | ${1} + ${'foo'} | ${null} `( 'returns $returnValue when value of `issuable.author.id` is $authorId', async ({ authorId, returnValue }) => { @@ -60,6 +68,30 @@ describe('IssuableItem', () => { ); }); + describe('isIssuableUrlExternal', () => { + it.each` + issuableWebUrl | urlType | returnValue + ${'/gitlab-org/gitlab-test/-/issues/2'} | ${'relative'} | ${false} + ${'http://0.0.0.0:3000/gitlab-org/gitlab-test/-/issues/1'} | ${'absolute and internal'} | ${false} + ${'http://jira.atlassian.net/browse/IG-1'} | ${'external'} | ${true} + ${'https://github.com/gitlabhq/gitlabhq/issues/1'} | ${'external'} | ${true} + `( + 'returns $returnValue when `issuable.webUrl` is $urlType', + async ({ issuableWebUrl, returnValue }) => { + wrapper.setProps({ + issuable: { + ...mockIssuable, + webUrl: issuableWebUrl, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.isIssuableUrlExternal).toBe(returnValue); + }, + ); + }); + describe('labels', () => { it('returns `issuable.labels.nodes` reference when it is available', () => { expect(wrapper.vm.labels).toEqual(mockLabels); @@ -92,6 +124,12 @@ describe('IssuableItem', () => { }); }); + describe('assignees', () => { + it('returns `issuable.assignees` reference when it is available', () => { + expect(wrapper.vm.assignees).toBe(mockIssuable.assignees); + }); + }); + describe('createdAt', () => { it('returns string containing timeago string based on `issuable.createdAt`', () => { expect(wrapper.vm.createdAt).toContain('created'); @@ -105,6 +143,31 @@ describe('IssuableItem', () => { expect(wrapper.vm.updatedAt).toContain('ago'); }); }); + + describe('showDiscussions', () => { + it.each` + userDiscussionsCount | returnValue + ${0} | ${true} + ${1} | ${true} + ${undefined} | ${false} + ${null} | ${false} + `( + 'returns $returnValue when issuable.userDiscussionsCount is $userDiscussionsCount', + ({ userDiscussionsCount, returnValue }) => { + const wrapperWithDiscussions = createComponent({ + issuableSymbol: '#', + issuable: { + ...mockIssuable, + userDiscussionsCount, + }, + }); + + expect(wrapperWithDiscussions.vm.showDiscussions).toBe(returnValue); + + wrapperWithDiscussions.destroy(); + }, + ); + }); }); describe('methods', () => { @@ -120,6 +183,34 @@ describe('IssuableItem', () => { }, ); }); + + describe('labelTitle', () => { + it.each` + label | propWithTitle | returnValue + ${{ title: 'foo' }} | ${'title'} | ${'foo'} + ${{ name: 'foo' }} | ${'name'} | ${'foo'} + `('returns string value of `label.$propWithTitle`', ({ label, returnValue }) => { + expect(wrapper.vm.labelTitle(label)).toBe(returnValue); + }); + }); + + describe('labelTarget', () => { + it('returns target string for a provided label param when `enableLabelPermalinks` is true', () => { + expect(wrapper.vm.labelTarget(mockRegularLabel)).toBe( + '?label_name%5B%5D=Documentation%20Update', + ); + }); + + it('returns string "#" for a provided label param when `enableLabelPermalinks` is false', async () => { + wrapper.setProps({ + enableLabelPermalinks: false, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.labelTarget(mockRegularLabel)).toBe('#'); + }); + }); }); describe('template', () => { @@ -128,9 +219,47 @@ describe('IssuableItem', () => { expect(titleEl.exists()).toBe(true); expect(titleEl.find(GlLink).attributes('href')).toBe(mockIssuable.webUrl); + expect(titleEl.find(GlLink).attributes('target')).not.toBeDefined(); expect(titleEl.find(GlLink).text()).toBe(mockIssuable.title); }); + it('renders checkbox when `showCheckbox` prop is true', async () => { + wrapper.setProps({ + showCheckbox: true, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlFormCheckbox).exists()).toBe(true); + expect(wrapper.find(GlFormCheckbox).attributes('checked')).not.toBeDefined(); + + wrapper.setProps({ + checked: true, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlFormCheckbox).attributes('checked')).toBe('true'); + }); + + it('renders issuable title with `target` set as "_blank" when issuable.webUrl is external', async () => { + wrapper.setProps({ + issuable: { + ...mockIssuable, + webUrl: 'http://jira.atlassian.net/browse/IG-1', + }, + }); + + await wrapper.vm.$nextTick(); + + expect( + wrapper + .find('[data-testid="issuable-title"]') + .find(GlLink) + .attributes('target'), + ).toBe('_blank'); + }); + it('renders issuable reference', () => { const referenceEl = wrapper.find('[data-testid="issuable-reference"]'); @@ -138,6 +267,24 @@ describe('IssuableItem', () => { expect(referenceEl.text()).toBe(`#${mockIssuable.iid}`); }); + it('renders issuable reference via slot', () => { + const wrapperWithRefSlot = createComponent({ + issuableSymbol: '#', + issuable: mockIssuable, + slots: { + reference: ` + <b class="js-reference">${mockIssuable.iid}</b> + `, + }, + }); + const referenceEl = wrapperWithRefSlot.find('.js-reference'); + + expect(referenceEl.exists()).toBe(true); + expect(referenceEl.text()).toBe(`${mockIssuable.iid}`); + + wrapperWithRefSlot.destroy(); + }); + it('renders issuable createdAt info', () => { const createdAtEl = wrapper.find('[data-testid="issuable-created-at"]'); @@ -151,7 +298,7 @@ describe('IssuableItem', () => { expect(authorEl.exists()).toBe(true); expect(authorEl.attributes()).toMatchObject({ - 'data-user-id': wrapper.vm.authorId, + 'data-user-id': `${wrapper.vm.authorId}`, 'data-username': mockAuthor.username, 'data-name': mockAuthor.name, 'data-avatar-url': mockAuthor.avatarUrl, @@ -160,6 +307,42 @@ describe('IssuableItem', () => { expect(authorEl.text()).toBe(mockAuthor.name); }); + it('renders issuable author info via slot', () => { + const wrapperWithAuthorSlot = createComponent({ + issuableSymbol: '#', + issuable: mockIssuable, + slots: { + reference: ` + <span class="js-author">${mockAuthor.name}</span> + `, + }, + }); + const authorEl = wrapperWithAuthorSlot.find('.js-author'); + + expect(authorEl.exists()).toBe(true); + expect(authorEl.text()).toBe(mockAuthor.name); + + wrapperWithAuthorSlot.destroy(); + }); + + it('renders timeframe via slot', () => { + const wrapperWithTimeframeSlot = createComponent({ + issuableSymbol: '#', + issuable: mockIssuable, + slots: { + timeframe: ` + <b class="js-timeframe">Jan 1, 2020 - Mar 31, 2020</b> + `, + }, + }); + const timeframeEl = wrapperWithTimeframeSlot.find('.js-timeframe'); + + expect(timeframeEl.exists()).toBe(true); + expect(timeframeEl.text()).toBe('Jan 1, 2020 - Mar 31, 2020'); + + wrapperWithTimeframeSlot.destroy(); + }); + it('renders gl-label component for each label present within `issuable` prop', () => { const labelsEl = wrapper.findAll(GlLabel); @@ -170,10 +353,52 @@ describe('IssuableItem', () => { title: mockLabels[0].title, description: mockLabels[0].description, scoped: false, + target: wrapper.vm.labelTarget(mockLabels[0]), size: 'sm', }); }); + it('renders issuable status via slot', () => { + const wrapperWithStatusSlot = createComponent({ + issuableSymbol: '#', + issuable: mockIssuable, + slots: { + status: ` + <b class="js-status">${mockIssuable.state}</b> + `, + }, + }); + const statusEl = wrapperWithStatusSlot.find('.js-status'); + + expect(statusEl.exists()).toBe(true); + expect(statusEl.text()).toBe(`${mockIssuable.state}`); + + wrapperWithStatusSlot.destroy(); + }); + + it('renders discussions count', () => { + const discussionsEl = wrapper.find('[data-testid="issuable-discussions"]'); + + expect(discussionsEl.exists()).toBe(true); + expect(discussionsEl.find(GlLink).attributes()).toMatchObject({ + title: 'Comments', + href: `${mockIssuable.webUrl}#notes`, + }); + expect(discussionsEl.find(GlIcon).props('name')).toBe('comments'); + expect(discussionsEl.find(GlLink).text()).toContain('2'); + }); + + it('renders issuable-assignees component', () => { + const assigneesEl = wrapper.find(IssuableAssignees); + + expect(assigneesEl.exists()).toBe(true); + expect(assigneesEl.props()).toMatchObject({ + assignees: mockIssuable.assignees, + iconSize: 16, + maxVisible: 4, + }); + }); + it('renders issuable updatedAt info', () => { const updatedAtEl = wrapper.find('[data-testid="issuable-updated-at"]'); diff --git a/spec/frontend/issuable_list/components/issuable_list_root_spec.js b/spec/frontend/issuable_list/components/issuable_list_root_spec.js index 34184522b55..add5d9e8e2d 100644 --- a/spec/frontend/issuable_list/components/issuable_list_root_spec.js +++ b/spec/frontend/issuable_list/components/issuable_list_root_spec.js @@ -1,16 +1,21 @@ import { mount } from '@vue/test-utils'; -import { GlLoadingIcon, GlPagination } from '@gitlab/ui'; +import { GlSkeletonLoading, GlPagination } from '@gitlab/ui'; + +import { TEST_HOST } from 'helpers/test_constants'; import IssuableListRoot from '~/issuable_list/components/issuable_list_root.vue'; import IssuableTabs from '~/issuable_list/components/issuable_tabs.vue'; import IssuableItem from '~/issuable_list/components/issuable_item.vue'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import { mockIssuableListProps } from '../mock_data'; +import { mockIssuableListProps, mockIssuables } from '../mock_data'; -const createComponent = (propsData = mockIssuableListProps) => +const createComponent = ({ props = mockIssuableListProps, data = {} } = {}) => mount(IssuableListRoot, { - propsData, + propsData: props, + data() { + return data; + }, slots: { 'nav-actions': ` <button class="js-new-issuable">New issuable</button> @@ -32,6 +37,139 @@ describe('IssuableListRoot', () => { wrapper.destroy(); }); + describe('computed', () => { + const mockCheckedIssuables = { + [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] }, + [mockIssuables[1].iid]: { checked: true, issuable: mockIssuables[1] }, + [mockIssuables[2].iid]: { checked: true, issuable: mockIssuables[2] }, + }; + + const mIssuables = [mockIssuables[0], mockIssuables[1], mockIssuables[2]]; + + describe('skeletonItemCount', () => { + it.each` + totalItems | defaultPageSize | currentPage | returnValue + ${100} | ${20} | ${1} | ${20} + ${105} | ${20} | ${6} | ${5} + ${7} | ${20} | ${1} | ${7} + ${0} | ${20} | ${1} | ${5} + `( + 'returns $returnValue when totalItems is $totalItems, defaultPageSize is $defaultPageSize and currentPage is $currentPage', + async ({ totalItems, defaultPageSize, currentPage, returnValue }) => { + wrapper.setProps({ + totalItems, + defaultPageSize, + currentPage, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.skeletonItemCount).toBe(returnValue); + }, + ); + }); + + describe('allIssuablesChecked', () => { + it.each` + checkedIssuables | issuables | specTitle | returnValue + ${mockCheckedIssuables} | ${mIssuables} | ${'same as'} | ${true} + ${{}} | ${mIssuables} | ${'not same as'} | ${false} + `( + 'returns $returnValue when bulkEditIssuables count is $specTitle issuables count', + async ({ checkedIssuables, issuables, returnValue }) => { + wrapper.setProps({ + issuables, + }); + + await wrapper.vm.$nextTick(); + + wrapper.setData({ + checkedIssuables, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.allIssuablesChecked).toBe(returnValue); + }, + ); + }); + + describe('bulkEditIssuables', () => { + it('returns array of issuables which have `checked` set to true within checkedIssuables map', async () => { + wrapper.setData({ + checkedIssuables: mockCheckedIssuables, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.bulkEditIssuables).toHaveLength(mIssuables.length); + }); + }); + }); + + describe('watch', () => { + describe('issuables', () => { + it('populates `checkedIssuables` prop with all issuables', async () => { + wrapper.setProps({ + issuables: [mockIssuables[0]], + }); + + await wrapper.vm.$nextTick(); + + expect(Object.keys(wrapper.vm.checkedIssuables)).toHaveLength(1); + expect(wrapper.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({ + checked: false, + issuable: mockIssuables[0], + }); + }); + }); + + describe('urlParams', () => { + it('updates window URL reflecting props within `urlParams`', async () => { + const urlParams = { + state: 'closed', + sort: 'updated_asc', + page: 1, + search: 'foo', + }; + + wrapper.setProps({ + urlParams, + }); + + await wrapper.vm.$nextTick(); + + expect(global.window.location.href).toBe( + `${TEST_HOST}/?state=${urlParams.state}&sort=${urlParams.sort}&page=${urlParams.page}&search=${urlParams.search}`, + ); + }); + }); + }); + + describe('methods', () => { + describe('issuableId', () => { + it('returns id value from provided issuable object', () => { + expect(wrapper.vm.issuableId({ id: 1 })).toBe(1); + expect(wrapper.vm.issuableId({ iid: 1 })).toBe(1); + expect(wrapper.vm.issuableId({})).toBeDefined(); + }); + }); + + describe('issuableChecked', () => { + it('returns boolean value representing checked status of issuable item', async () => { + wrapper.setData({ + checkedIssuables: { + [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] }, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.issuableChecked(mockIssuables[0])).toBe(true); + }); + }); + }); + describe('template', () => { it('renders component container element with class "issuable-list-container"', () => { expect(wrapper.classes()).toContain('issuable-list-container'); @@ -86,7 +224,7 @@ describe('IssuableListRoot', () => { await wrapper.vm.$nextTick(); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.findAll(GlSkeletonLoading)).toHaveLength(wrapper.vm.skeletonItemCount); }); it('renders issuable-item component for each item within `issuables` array', () => { @@ -114,6 +252,7 @@ describe('IssuableListRoot', () => { it('renders gl-pagination when `showPaginationControls` prop is true', async () => { wrapper.setProps({ showPaginationControls: true, + totalItems: 10, }); await wrapper.vm.$nextTick(); @@ -125,18 +264,51 @@ describe('IssuableListRoot', () => { value: 1, prevPage: 0, nextPage: 2, + totalItems: 10, align: 'center', }); }); }); describe('events', () => { + let wrapperChecked; + + beforeEach(() => { + wrapperChecked = createComponent({ + data: { + checkedIssuables: { + [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] }, + }, + }, + }); + }); + + afterEach(() => { + wrapperChecked.destroy(); + }); + it('issuable-tabs component emits `click-tab` event on `click-tab` event', () => { wrapper.find(IssuableTabs).vm.$emit('click'); expect(wrapper.emitted('click-tab')).toBeTruthy(); }); + it('sets all issuables as checked when filtered-search-bar component emits `checked-input` event', async () => { + const searchEl = wrapperChecked.find(FilteredSearchBar); + + searchEl.vm.$emit('checked-input', true); + + await wrapperChecked.vm.$nextTick(); + + expect(searchEl.emitted('checked-input')).toBeTruthy(); + expect(searchEl.emitted('checked-input').length).toBe(1); + + expect(wrapperChecked.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({ + checked: true, + issuable: mockIssuables[0], + }); + }); + it('filtered-search-bar component emits `filter` event on `onFilter` & `sort` event on `onSort` events', () => { const searchEl = wrapper.find(FilteredSearchBar); @@ -146,6 +318,22 @@ describe('IssuableListRoot', () => { expect(wrapper.emitted('sort')).toBeTruthy(); }); + it('sets an issuable as checked when issuable-item component emits `checked-input` event', async () => { + const issuableItem = wrapperChecked.findAll(IssuableItem).at(0); + + issuableItem.vm.$emit('checked-input', true); + + await wrapperChecked.vm.$nextTick(); + + expect(issuableItem.emitted('checked-input')).toBeTruthy(); + expect(issuableItem.emitted('checked-input').length).toBe(1); + + expect(wrapperChecked.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({ + checked: true, + issuable: mockIssuables[0], + }); + }); + it('gl-pagination component emits `page-change` event on `input` event', async () => { wrapper.setProps({ showPaginationControls: true, diff --git a/spec/frontend/issuable_list/mock_data.js b/spec/frontend/issuable_list/mock_data.js index 8eab2ca6f94..e19a337473a 100644 --- a/spec/frontend/issuable_list/mock_data.js +++ b/spec/frontend/issuable_list/mock_data.js @@ -51,6 +51,8 @@ export const mockIssuable = { labels: { nodes: mockLabels, }, + assignees: [mockAuthor], + userDiscussionsCount: 2, }; export const mockIssuables = [ |