diff options
Diffstat (limited to 'spec/frontend/boards')
36 files changed, 2395 insertions, 922 deletions
diff --git a/spec/frontend/boards/board_list_deprecated_spec.js b/spec/frontend/boards/board_list_deprecated_spec.js new file mode 100644 index 00000000000..393d7f954b1 --- /dev/null +++ b/spec/frontend/boards/board_list_deprecated_spec.js @@ -0,0 +1,275 @@ +/* global List */ +/* global ListIssue */ + +import Vue from 'vue'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import eventHub from '~/boards/eventhub'; +import BoardList from '~/boards/components/board_list_deprecated.vue'; +import '~/boards/models/issue'; +import '~/boards/models/list'; +import { listObj, boardsMockInterceptor } from './mock_data'; +import store from '~/boards/stores'; +import boardsStore from '~/boards/stores/boards_store'; + +const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listProps = {} }) => { + const el = document.createElement('div'); + + document.body.appendChild(el); + const mock = new MockAdapter(axios); + mock.onAny().reply(boardsMockInterceptor); + boardsStore.create(); + + const BoardListComp = Vue.extend(BoardList); + const list = new List({ ...listObj, ...listProps }); + const issue = new ListIssue({ + title: 'Testing', + id: 1, + iid: 1, + confidential: false, + labels: [], + assignees: [], + ...listIssueProps, + }); + if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) { + list.issuesSize = 1; + } + list.issues.push(issue); + + const component = new BoardListComp({ + el, + store, + propsData: { + disabled: false, + list, + issues: list.issues, + ...componentProps, + }, + provide: { + groupId: null, + rootPath: '/', + }, + }).$mount(); + + Vue.nextTick(() => { + done(); + }); + + return { component, mock }; +}; + +describe('Board list component', () => { + let mock; + let component; + let getIssues; + function generateIssues(compWrapper) { + for (let i = 1; i < 20; i += 1) { + const issue = { ...compWrapper.list.issues[0] }; + issue.id += i; + compWrapper.list.issues.push(issue); + } + } + + describe('When Expanded', () => { + beforeEach((done) => { + getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {})); + ({ mock, component } = createComponent({ done })); + }); + + afterEach(() => { + mock.restore(); + component.$destroy(); + }); + + it('loads first page of issues', () => { + return waitForPromises().then(() => { + expect(getIssues).toHaveBeenCalled(); + }); + }); + + it('renders component', () => { + expect(component.$el.classList.contains('board-list-component')).toBe(true); + }); + + it('renders loading icon', () => { + component.list.loading = true; + + return Vue.nextTick().then(() => { + expect(component.$el.querySelector('.board-list-loading')).not.toBeNull(); + }); + }); + + it('renders issues', () => { + expect(component.$el.querySelectorAll('.board-card').length).toBe(1); + }); + + it('sets data attribute with issue id', () => { + expect(component.$el.querySelector('.board-card').getAttribute('data-issue-id')).toBe('1'); + }); + + it('shows new issue form', () => { + component.toggleForm(); + + return Vue.nextTick().then(() => { + expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); + + expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); + }); + }); + + it('shows new issue form after eventhub event', () => { + eventHub.$emit(`toggle-issue-form-${component.list.id}`); + + return Vue.nextTick().then(() => { + expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); + + expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); + }); + }); + + it('does not show new issue form for closed list', () => { + component.list.type = 'closed'; + component.toggleForm(); + + return Vue.nextTick().then(() => { + expect(component.$el.querySelector('.board-new-issue-form')).toBeNull(); + }); + }); + + it('shows count list item', () => { + component.showCount = true; + + return Vue.nextTick().then(() => { + expect(component.$el.querySelector('.board-list-count')).not.toBeNull(); + + expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( + 'Showing all issues', + ); + }); + }); + + it('sets data attribute with invalid id', () => { + component.showCount = true; + + return Vue.nextTick().then(() => { + expect(component.$el.querySelector('.board-list-count').getAttribute('data-issue-id')).toBe( + '-1', + ); + }); + }); + + it('shows how many more issues to load', () => { + component.showCount = true; + component.list.issuesSize = 20; + + return Vue.nextTick().then(() => { + expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( + 'Showing 1 of 20 issues', + ); + }); + }); + + it('loads more issues after scrolling', () => { + jest.spyOn(component.list, 'nextPage').mockImplementation(() => {}); + generateIssues(component); + component.$refs.list.dispatchEvent(new Event('scroll')); + + return waitForPromises().then(() => { + expect(component.list.nextPage).toHaveBeenCalled(); + }); + }); + + it('does not load issues if already loading', () => { + component.list.nextPage = jest + .spyOn(component.list, 'nextPage') + .mockReturnValue(new Promise(() => {})); + + component.onScroll(); + component.onScroll(); + + return waitForPromises().then(() => { + expect(component.list.nextPage).toHaveBeenCalledTimes(1); + }); + }); + + it('shows loading more spinner', () => { + component.showCount = true; + component.list.loadingMore = true; + + return Vue.nextTick().then(() => { + expect(component.$el.querySelector('.board-list-count .gl-spinner')).not.toBeNull(); + }); + }); + }); + + describe('When Collapsed', () => { + beforeEach((done) => { + getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {})); + ({ mock, component } = createComponent({ + done, + listProps: { type: 'closed', collapsed: true, issuesSize: 50 }, + })); + generateIssues(component); + component.scrollHeight = jest.spyOn(component, 'scrollHeight').mockReturnValue(0); + }); + + afterEach(() => { + mock.restore(); + component.$destroy(); + }); + + it('does not load all issues', () => { + return waitForPromises().then(() => { + // Initial getIssues from list constructor + expect(getIssues).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('max issue count warning', () => { + beforeEach((done) => { + ({ mock, component } = createComponent({ + done, + listProps: { type: 'closed', collapsed: true, issuesSize: 50 }, + })); + }); + + afterEach(() => { + mock.restore(); + component.$destroy(); + }); + + describe('when issue count exceeds max issue count', () => { + it('sets background to bg-danger-100', () => { + component.list.issuesSize = 4; + component.list.maxIssueCount = 3; + + return Vue.nextTick().then(() => { + expect(component.$el.querySelector('.bg-danger-100')).not.toBeNull(); + }); + }); + }); + + describe('when list issue count does NOT exceed list max issue count', () => { + it('does not sets background to bg-danger-100', () => { + component.list.issuesSize = 2; + component.list.maxIssueCount = 3; + + return Vue.nextTick().then(() => { + expect(component.$el.querySelector('.bg-danger-100')).toBeNull(); + }); + }); + }); + + describe('when list max issue count is 0', () => { + it('does not sets background to bg-danger-100', () => { + component.list.maxIssueCount = 0; + + return Vue.nextTick().then(() => { + expect(component.$el.querySelector('.bg-danger-100')).toBeNull(); + }); + }); + }); + }); +}); diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js index 80d7a72151d..f82b1f7ed5c 100644 --- a/spec/frontend/boards/board_list_helper.js +++ b/spec/frontend/boards/board_list_helper.js @@ -5,7 +5,7 @@ import MockAdapter from 'axios-mock-adapter'; import Vue from 'vue'; import Sortable from 'sortablejs'; import axios from '~/lib/utils/axios_utils'; -import BoardList from '~/boards/components/board_list.vue'; +import BoardList from '~/boards/components/board_list_deprecated.vue'; import '~/boards/models/issue'; import '~/boards/models/list'; diff --git a/spec/frontend/boards/board_list_new_spec.js b/spec/frontend/boards/board_list_new_spec.js deleted file mode 100644 index 96b03ed927e..00000000000 --- a/spec/frontend/boards/board_list_new_spec.js +++ /dev/null @@ -1,268 +0,0 @@ -import Vuex from 'vuex'; -import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; -import { createLocalVue, mount } from '@vue/test-utils'; -import eventHub from '~/boards/eventhub'; -import BoardList from '~/boards/components/board_list_new.vue'; -import BoardCard from '~/boards/components/board_card.vue'; -import '~/boards/models/list'; -import { mockList, mockIssuesByListId, issues, mockIssues } from './mock_data'; -import defaultState from '~/boards/stores/state'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -const actions = { - fetchIssuesForList: jest.fn(), -}; - -const createStore = (state = defaultState) => { - return new Vuex.Store({ - state, - actions, - }); -}; - -const createComponent = ({ - listIssueProps = {}, - componentProps = {}, - listProps = {}, - state = {}, -} = {}) => { - const store = createStore({ - issuesByListId: mockIssuesByListId, - issues, - pageInfoByListId: { - 'gid://gitlab/List/1': { hasNextPage: true }, - 'gid://gitlab/List/2': {}, - }, - listsFlags: { - 'gid://gitlab/List/1': {}, - 'gid://gitlab/List/2': {}, - }, - ...state, - }); - - const list = { - ...mockList, - ...listProps, - }; - const issue = { - title: 'Testing', - id: 1, - iid: 1, - confidential: false, - labels: [], - assignees: [], - ...listIssueProps, - }; - if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesCount')) { - list.issuesCount = 1; - } - - const component = mount(BoardList, { - localVue, - propsData: { - disabled: false, - list, - issues: [issue], - canAdminList: true, - ...componentProps, - }, - store, - provide: { - groupId: null, - rootPath: '/', - weightFeatureAvailable: false, - boardWeight: null, - }, - }); - - return component; -}; - -describe('Board list component', () => { - let wrapper; - const findByTestId = testId => wrapper.find(`[data-testid="${testId}"]`); - useFakeRequestAnimationFrame(); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('When Expanded', () => { - beforeEach(() => { - wrapper = createComponent(); - }); - - it('renders component', () => { - expect(wrapper.find('.board-list-component').exists()).toBe(true); - }); - - it('renders loading icon', () => { - wrapper = createComponent({ - state: { listsFlags: { 'gid://gitlab/List/1': { isLoading: true } } }, - }); - - expect(findByTestId('board_list_loading').exists()).toBe(true); - }); - - it('renders issues', () => { - expect(wrapper.findAll(BoardCard).length).toBe(1); - }); - - it('sets data attribute with issue id', () => { - expect(wrapper.find('.board-card').attributes('data-issue-id')).toBe('1'); - }); - - it('shows new issue form', async () => { - wrapper.vm.toggleForm(); - - await wrapper.vm.$nextTick(); - expect(wrapper.find('.board-new-issue-form').exists()).toBe(true); - }); - - it('shows new issue form after eventhub event', async () => { - eventHub.$emit(`toggle-issue-form-${wrapper.vm.list.id}`); - - await wrapper.vm.$nextTick(); - expect(wrapper.find('.board-new-issue-form').exists()).toBe(true); - }); - - it('does not show new issue form for closed list', () => { - wrapper.setProps({ list: { type: 'closed' } }); - wrapper.vm.toggleForm(); - - expect(wrapper.find('.board-new-issue-form').exists()).toBe(false); - }); - - it('shows count list item', async () => { - wrapper.vm.showCount = true; - - await wrapper.vm.$nextTick(); - expect(wrapper.find('.board-list-count').exists()).toBe(true); - - expect(wrapper.find('.board-list-count').text()).toBe('Showing all issues'); - }); - - it('sets data attribute with invalid id', async () => { - wrapper.vm.showCount = true; - - await wrapper.vm.$nextTick(); - expect(wrapper.find('.board-list-count').attributes('data-issue-id')).toBe('-1'); - }); - - it('shows how many more issues to load', async () => { - wrapper.vm.showCount = true; - wrapper.setProps({ list: { issuesCount: 20 } }); - - await wrapper.vm.$nextTick(); - expect(wrapper.find('.board-list-count').text()).toBe('Showing 1 of 20 issues'); - }); - }); - - describe('load more issues', () => { - beforeEach(() => { - wrapper = createComponent({ - listProps: { issuesCount: 25 }, - }); - }); - - it('loads more issues after scrolling', () => { - wrapper.vm.listRef.dispatchEvent(new Event('scroll')); - - expect(actions.fetchIssuesForList).toHaveBeenCalled(); - }); - - it('does not load issues if already loading', () => { - wrapper = createComponent({ - state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } }, - }); - wrapper.vm.listRef.dispatchEvent(new Event('scroll')); - - expect(actions.fetchIssuesForList).not.toHaveBeenCalled(); - }); - - it('shows loading more spinner', async () => { - wrapper = createComponent({ - state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } }, - }); - wrapper.vm.showCount = true; - - await wrapper.vm.$nextTick(); - expect(wrapper.find('.board-list-count .gl-spinner').exists()).toBe(true); - }); - }); - - describe('max issue count warning', () => { - beforeEach(() => { - wrapper = createComponent({ - listProps: { issuesCount: 50 }, - }); - }); - - describe('when issue count exceeds max issue count', () => { - it('sets background to bg-danger-100', async () => { - wrapper.setProps({ list: { issuesCount: 4, maxIssueCount: 3 } }); - - await wrapper.vm.$nextTick(); - expect(wrapper.find('.bg-danger-100').exists()).toBe(true); - }); - }); - - describe('when list issue count does NOT exceed list max issue count', () => { - it('does not sets background to bg-danger-100', () => { - wrapper.setProps({ list: { issuesCount: 2, maxIssueCount: 3 } }); - - expect(wrapper.find('.bg-danger-100').exists()).toBe(false); - }); - }); - - describe('when list max issue count is 0', () => { - it('does not sets background to bg-danger-100', () => { - wrapper.setProps({ list: { maxIssueCount: 0 } }); - - expect(wrapper.find('.bg-danger-100').exists()).toBe(false); - }); - }); - }); - - describe('drag & drop issue', () => { - beforeEach(() => { - wrapper = createComponent(); - }); - - describe('handleDragOnStart', () => { - it('adds a class `is-dragging` to document body', () => { - expect(document.body.classList.contains('is-dragging')).toBe(false); - - findByTestId('tree-root-wrapper').vm.$emit('start'); - - expect(document.body.classList.contains('is-dragging')).toBe(true); - }); - }); - - describe('handleDragOnEnd', () => { - it('removes class `is-dragging` from document body', () => { - jest.spyOn(wrapper.vm, 'moveIssue').mockImplementation(() => {}); - document.body.classList.add('is-dragging'); - - findByTestId('tree-root-wrapper').vm.$emit('end', { - oldIndex: 1, - newIndex: 0, - item: { - dataset: { - issueId: mockIssues[0].id, - issueIid: mockIssues[0].iid, - issuePath: mockIssues[0].referencePath, - }, - }, - to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } }, - from: { dataset: { listId: 'gid://gitlab/List/2' } }, - }); - - expect(document.body.classList.contains('is-dragging')).toBe(false); - }); - }); - }); -}); diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index 0fe3c88f518..1b62f25044e 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -1,29 +1,51 @@ -/* global List */ -/* global ListIssue */ - -import Vue from 'vue'; -import MockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; +import Vuex from 'vuex'; +import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; +import { createLocalVue, mount } from '@vue/test-utils'; import eventHub from '~/boards/eventhub'; -import waitForPromises from '../helpers/wait_for_promises'; import BoardList from '~/boards/components/board_list.vue'; -import '~/boards/models/issue'; -import '~/boards/models/list'; -import { listObj, boardsMockInterceptor } from './mock_data'; -import store from '~/boards/stores'; -import boardsStore from '~/boards/stores/boards_store'; - -const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listProps = {} }) => { - const el = document.createElement('div'); - - document.body.appendChild(el); - const mock = new MockAdapter(axios); - mock.onAny().reply(boardsMockInterceptor); - boardsStore.create(); - - const BoardListComp = Vue.extend(BoardList); - const list = new List({ ...listObj, ...listProps }); - const issue = new ListIssue({ +import BoardCard from '~/boards/components/board_card.vue'; +import { mockList, mockIssuesByListId, issues, mockIssues } from './mock_data'; +import defaultState from '~/boards/stores/state'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +const actions = { + fetchIssuesForList: jest.fn(), +}; + +const createStore = (state = defaultState) => { + return new Vuex.Store({ + state, + actions, + }); +}; + +const createComponent = ({ + listIssueProps = {}, + componentProps = {}, + listProps = {}, + state = {}, +} = {}) => { + const store = createStore({ + issuesByListId: mockIssuesByListId, + issues, + pageInfoByListId: { + 'gid://gitlab/List/1': { hasNextPage: true }, + 'gid://gitlab/List/2': {}, + }, + listsFlags: { + 'gid://gitlab/List/1': {}, + 'gid://gitlab/List/2': {}, + }, + ...state, + }); + + const list = { + ...mockList, + ...listProps, + }; + const issue = { title: 'Testing', id: 1, iid: 1, @@ -31,244 +53,214 @@ const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listP labels: [], assignees: [], ...listIssueProps, - }); - if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) { - list.issuesSize = 1; + }; + if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesCount')) { + list.issuesCount = 1; } - list.issues.push(issue); - const component = new BoardListComp({ - el, - store, + const component = mount(BoardList, { + localVue, propsData: { disabled: false, list, - issues: list.issues, + issues: [issue], + canAdminList: true, ...componentProps, }, + store, provide: { groupId: null, rootPath: '/', + weightFeatureAvailable: false, + boardWeight: null, }, - }).$mount(); - - Vue.nextTick(() => { - done(); }); - return { component, mock }; + return component; }; describe('Board list component', () => { - let mock; - let component; - let getIssues; - function generateIssues(compWrapper) { - for (let i = 1; i < 20; i += 1) { - const issue = { ...compWrapper.list.issues[0] }; - issue.id += i; - compWrapper.list.issues.push(issue); - } - } + let wrapper; + const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); + useFakeRequestAnimationFrame(); - describe('When Expanded', () => { - beforeEach(done => { - getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {})); - ({ mock, component } = createComponent({ done })); - }); - - afterEach(() => { - mock.restore(); - component.$destroy(); - }); + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); - it('loads first page of issues', () => { - return waitForPromises().then(() => { - expect(getIssues).toHaveBeenCalled(); - }); + describe('When Expanded', () => { + beforeEach(() => { + wrapper = createComponent(); }); it('renders component', () => { - expect(component.$el.classList.contains('board-list-component')).toBe(true); + expect(wrapper.find('.board-list-component').exists()).toBe(true); }); it('renders loading icon', () => { - component.list.loading = true; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-list-loading')).not.toBeNull(); + wrapper = createComponent({ + state: { listsFlags: { 'gid://gitlab/List/1': { isLoading: true } } }, }); + + expect(findByTestId('board_list_loading').exists()).toBe(true); }); it('renders issues', () => { - expect(component.$el.querySelectorAll('.board-card').length).toBe(1); + expect(wrapper.findAll(BoardCard).length).toBe(1); }); it('sets data attribute with issue id', () => { - expect(component.$el.querySelector('.board-card').getAttribute('data-issue-id')).toBe('1'); + expect(wrapper.find('.board-card').attributes('data-issue-id')).toBe('1'); }); - it('shows new issue form', () => { - component.toggleForm(); - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); + it('shows new issue form', async () => { + wrapper.vm.toggleForm(); - expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); - }); + await wrapper.vm.$nextTick(); + expect(wrapper.find('.board-new-issue-form').exists()).toBe(true); }); - it('shows new issue form after eventhub event', () => { - eventHub.$emit(`toggle-issue-form-${component.list.id}`); - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); + it('shows new issue form after eventhub event', async () => { + eventHub.$emit(`toggle-issue-form-${wrapper.vm.list.id}`); - expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); - }); + await wrapper.vm.$nextTick(); + expect(wrapper.find('.board-new-issue-form').exists()).toBe(true); }); it('does not show new issue form for closed list', () => { - component.list.type = 'closed'; - component.toggleForm(); + wrapper.setProps({ list: { type: 'closed' } }); + wrapper.vm.toggleForm(); - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-new-issue-form')).toBeNull(); - }); + expect(wrapper.find('.board-new-issue-form').exists()).toBe(false); }); - it('shows count list item', () => { - component.showCount = true; + it('shows count list item', async () => { + wrapper.vm.showCount = true; - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-list-count')).not.toBeNull(); + await wrapper.vm.$nextTick(); + expect(wrapper.find('.board-list-count').exists()).toBe(true); - expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( - 'Showing all issues', - ); - }); + expect(wrapper.find('.board-list-count').text()).toBe('Showing all issues'); }); - it('sets data attribute with invalid id', () => { - component.showCount = true; + it('sets data attribute with invalid id', async () => { + wrapper.vm.showCount = true; - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-list-count').getAttribute('data-issue-id')).toBe( - '-1', - ); - }); + await wrapper.vm.$nextTick(); + expect(wrapper.find('.board-list-count').attributes('data-issue-id')).toBe('-1'); }); - it('shows how many more issues to load', () => { - component.showCount = true; - component.list.issuesSize = 20; + it('shows how many more issues to load', async () => { + wrapper.vm.showCount = true; + wrapper.setProps({ list: { issuesCount: 20 } }); - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( - 'Showing 1 of 20 issues', - ); - }); + await wrapper.vm.$nextTick(); + expect(wrapper.find('.board-list-count').text()).toBe('Showing 1 of 20 issues'); }); + }); - it('loads more issues after scrolling', () => { - jest.spyOn(component.list, 'nextPage').mockImplementation(() => {}); - generateIssues(component); - component.$refs.list.dispatchEvent(new Event('scroll')); - - return waitForPromises().then(() => { - expect(component.list.nextPage).toHaveBeenCalled(); + describe('load more issues', () => { + beforeEach(() => { + wrapper = createComponent({ + listProps: { issuesCount: 25 }, }); }); - it('does not load issues if already loading', () => { - component.list.nextPage = jest - .spyOn(component.list, 'nextPage') - .mockReturnValue(new Promise(() => {})); - - component.onScroll(); - component.onScroll(); + it('loads more issues after scrolling', () => { + wrapper.vm.listRef.dispatchEvent(new Event('scroll')); - return waitForPromises().then(() => { - expect(component.list.nextPage).toHaveBeenCalledTimes(1); - }); + expect(actions.fetchIssuesForList).toHaveBeenCalled(); }); - it('shows loading more spinner', () => { - component.showCount = true; - component.list.loadingMore = true; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-list-count .gl-spinner')).not.toBeNull(); + it('does not load issues if already loading', () => { + wrapper = createComponent({ + state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } }, }); - }); - }); + wrapper.vm.listRef.dispatchEvent(new Event('scroll')); - describe('When Collapsed', () => { - beforeEach(done => { - getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {})); - ({ mock, component } = createComponent({ - done, - listProps: { type: 'closed', collapsed: true, issuesSize: 50 }, - })); - generateIssues(component); - component.scrollHeight = jest.spyOn(component, 'scrollHeight').mockReturnValue(0); + expect(actions.fetchIssuesForList).not.toHaveBeenCalled(); }); - afterEach(() => { - mock.restore(); - component.$destroy(); - }); - - it('does not load all issues', () => { - return waitForPromises().then(() => { - // Initial getIssues from list constructor - expect(getIssues).toHaveBeenCalledTimes(1); + it('shows loading more spinner', async () => { + wrapper = createComponent({ + state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } }, }); + wrapper.vm.showCount = true; + + await wrapper.vm.$nextTick(); + expect(wrapper.find('.board-list-count .gl-spinner').exists()).toBe(true); }); }); describe('max issue count warning', () => { - beforeEach(done => { - ({ mock, component } = createComponent({ - done, - listProps: { type: 'closed', collapsed: true, issuesSize: 50 }, - })); - }); - - afterEach(() => { - mock.restore(); - component.$destroy(); + beforeEach(() => { + wrapper = createComponent({ + listProps: { issuesCount: 50 }, + }); }); describe('when issue count exceeds max issue count', () => { - it('sets background to bg-danger-100', () => { - component.list.issuesSize = 4; - component.list.maxIssueCount = 3; + it('sets background to bg-danger-100', async () => { + wrapper.setProps({ list: { issuesCount: 4, maxIssueCount: 3 } }); - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.bg-danger-100')).not.toBeNull(); - }); + await wrapper.vm.$nextTick(); + expect(wrapper.find('.bg-danger-100').exists()).toBe(true); }); }); describe('when list issue count does NOT exceed list max issue count', () => { it('does not sets background to bg-danger-100', () => { - component.list.issuesSize = 2; - component.list.maxIssueCount = 3; + wrapper.setProps({ list: { issuesCount: 2, maxIssueCount: 3 } }); - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.bg-danger-100')).toBeNull(); - }); + expect(wrapper.find('.bg-danger-100').exists()).toBe(false); }); }); describe('when list max issue count is 0', () => { it('does not sets background to bg-danger-100', () => { - component.list.maxIssueCount = 0; + wrapper.setProps({ list: { maxIssueCount: 0 } }); + + expect(wrapper.find('.bg-danger-100').exists()).toBe(false); + }); + }); + }); - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.bg-danger-100')).toBeNull(); + describe('drag & drop issue', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + describe('handleDragOnStart', () => { + it('adds a class `is-dragging` to document body', () => { + expect(document.body.classList.contains('is-dragging')).toBe(false); + + findByTestId('tree-root-wrapper').vm.$emit('start'); + + expect(document.body.classList.contains('is-dragging')).toBe(true); + }); + }); + + describe('handleDragOnEnd', () => { + it('removes class `is-dragging` from document body', () => { + jest.spyOn(wrapper.vm, 'moveIssue').mockImplementation(() => {}); + document.body.classList.add('is-dragging'); + + findByTestId('tree-root-wrapper').vm.$emit('end', { + oldIndex: 1, + newIndex: 0, + item: { + dataset: { + issueId: mockIssues[0].id, + issueIid: mockIssues[0].iid, + issuePath: mockIssues[0].referencePath, + }, + }, + to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } }, + from: { dataset: { listId: 'gid://gitlab/List/2' } }, }); + + expect(document.body.classList.contains('is-dragging')).toBe(false); }); }); }); diff --git a/spec/frontend/boards/board_new_issue_spec.js b/spec/frontend/boards/board_new_issue_deprecated_spec.js index 3eebfeca965..8236b468189 100644 --- a/spec/frontend/boards/board_new_issue_spec.js +++ b/spec/frontend/boards/board_new_issue_deprecated_spec.js @@ -4,7 +4,7 @@ import Vue from 'vue'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; -import boardNewIssue from '~/boards/components/board_new_issue.vue'; +import boardNewIssue from '~/boards/components/board_new_issue_deprecated.vue'; import boardsStore from '~/boards/stores/boards_store'; import '~/boards/models/list'; diff --git a/spec/frontend/boards/boards_store_spec.js b/spec/frontend/boards/boards_store_spec.js index c89f6d22ef2..f1d249ff069 100644 --- a/spec/frontend/boards/boards_store_spec.js +++ b/spec/frontend/boards/boards_store_spec.js @@ -77,7 +77,7 @@ describe('boardsStore', () => { beforeEach(() => { requestSpy = jest.fn(); - axiosMock.onPost(endpoints.listsEndpoint).replyOnce(config => requestSpy(config)); + axiosMock.onPost(endpoints.listsEndpoint).replyOnce((config) => requestSpy(config)); }); it('makes a request to create a list', () => { @@ -114,7 +114,7 @@ describe('boardsStore', () => { beforeEach(() => { requestSpy = jest.fn(); - axiosMock.onPut(`${endpoints.listsEndpoint}/${id}`).replyOnce(config => requestSpy(config)); + axiosMock.onPut(`${endpoints.listsEndpoint}/${id}`).replyOnce((config) => requestSpy(config)); }); it('makes a request to update a list position', () => { @@ -148,7 +148,7 @@ describe('boardsStore', () => { requestSpy = jest.fn(); axiosMock .onDelete(`${endpoints.listsEndpoint}/${id}`) - .replyOnce(config => requestSpy(config)); + .replyOnce((config) => requestSpy(config)); }); it('makes a request to delete a list', () => { @@ -269,7 +269,7 @@ describe('boardsStore', () => { requestSpy = jest.fn(); axiosMock .onPut(`${urlRoot}/-/boards/${boardId}/issues/${id}`) - .replyOnce(config => requestSpy(config)); + .replyOnce((config) => requestSpy(config)); }); it('makes a request to move an issue between lists', () => { @@ -308,7 +308,7 @@ describe('boardsStore', () => { beforeEach(() => { requestSpy = jest.fn(); - axiosMock.onPost(url).replyOnce(config => requestSpy(config)); + axiosMock.onPost(url).replyOnce((config) => requestSpy(config)); }); it('makes a request to create a new issue', () => { @@ -378,7 +378,7 @@ describe('boardsStore', () => { beforeEach(() => { requestSpy = jest.fn(); - axiosMock.onPost(endpoints.bulkUpdatePath).replyOnce(config => requestSpy(config)); + axiosMock.onPost(endpoints.bulkUpdatePath).replyOnce((config) => requestSpy(config)); }); it('makes a request to create a list', () => { @@ -456,24 +456,6 @@ describe('boardsStore', () => { }); }); - describe('deleteBoard', () => { - const id = 'capsized'; - const url = `${endpoints.boardsEndpoint}/${id}.json`; - - it('makes a request to delete a boards', () => { - axiosMock.onDelete(url).replyOnce(200, dummyResponse); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); - - return expect(boardsStore.deleteBoard({ id })).resolves.toEqual(expectedResponse); - }); - - it('fails for error response', () => { - axiosMock.onDelete(url).replyOnce(500); - - return expect(boardsStore.deleteBoard({ id })).rejects.toThrow(); - }); - }); - describe('when created', () => { beforeEach(() => { setupDefaultResponses(); @@ -603,7 +585,7 @@ describe('boardsStore', () => { expect(boardsStore.state.lists.length).toBe(1); - boardsStore.removeList(listObj.id, 'label'); + boardsStore.removeList(listObj.id); expect(boardsStore.state.lists.length).toBe(0); }); diff --git a/spec/frontend/boards/components/board_assignee_dropdown_spec.js b/spec/frontend/boards/components/board_assignee_dropdown_spec.js index bbdcc707f09..e52c14f9783 100644 --- a/spec/frontend/boards/components/board_assignee_dropdown_spec.js +++ b/spec/frontend/boards/components/board_assignee_dropdown_spec.js @@ -6,7 +6,7 @@ import { GlSearchBoxByType, GlLoadingIcon, } from '@gitlab/ui'; -import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; import VueApollo from 'vue-apollo'; import BoardAssigneeDropdown from '~/boards/components/board_assignee_dropdown.vue'; import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; @@ -93,8 +93,8 @@ describe('BoardCardAssigneeDropdown', () => { await wrapper.vm.$nextTick(); }; - const findByText = text => { - return wrapper.findAll(GlDropdownItem).wrappers.find(node => node.text().indexOf(text) === 0); + const findByText = (text) => { + return wrapper.findAll(GlDropdownItem).wrappers.find((node) => node.text().indexOf(text) === 0); }; const findLoadingIcon = () => wrapper.find(GlLoadingIcon); @@ -102,7 +102,7 @@ describe('BoardCardAssigneeDropdown', () => { beforeEach(() => { store.state.activeId = '1'; store.state.issues = { - '1': { + 1: { iid, assignees: [{ username: activeIssueName, name: activeIssueName, id: activeIssueName }], }, @@ -145,12 +145,7 @@ describe('BoardCardAssigneeDropdown', () => { it('renders gl-avatar-labeled in gl-avatar-link', () => { const item = findByText('hello'); - expect( - item - .find(GlAvatarLink) - .find(GlAvatarLabeled) - .exists(), - ).toBe(true); + expect(item.find(GlAvatarLink).find(GlAvatarLabeled).exists()).toBe(true); }); }); diff --git a/spec/frontend/boards/components/board_card_layout_spec.js b/spec/frontend/boards/components/board_card_layout_spec.js index 80f649a1a96..d8633871e8d 100644 --- a/spec/frontend/boards/components/board_card_layout_spec.js +++ b/spec/frontend/boards/components/board_card_layout_spec.js @@ -1,7 +1,8 @@ /* global List */ /* global ListLabel */ -import { shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; @@ -10,20 +11,35 @@ import axios from '~/lib/utils/axios_utils'; import '~/boards/models/label'; import '~/boards/models/assignee'; import '~/boards/models/list'; -import store from '~/boards/stores'; +import boardsVuexStore from '~/boards/stores'; import boardsStore from '~/boards/stores/boards_store'; import BoardCardLayout from '~/boards/components/board_card_layout.vue'; import issueCardInner from '~/boards/components/issue_card_inner.vue'; import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data'; +import { ISSUABLE } from '~/boards/constants'; + describe('Board card layout', () => { let wrapper; let mock; let list; + let store; + + const localVue = createLocalVue(); + localVue.use(Vuex); + + const createStore = ({ getters = {}, actions = {} } = {}) => { + store = new Vuex.Store({ + ...boardsVuexStore, + actions, + getters, + }); + }; // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized - const mountComponent = propsData => { + const mountComponent = ({ propsData = {}, provide = {} } = {}) => { wrapper = shallowMount(BoardCardLayout, { + localVue, stubs: { issueCardInner, }, @@ -38,6 +54,8 @@ describe('Board card layout', () => { provide: { groupId: null, rootPath: '/', + scopedLabelsAvailable: false, + ...provide, }, }); }; @@ -74,6 +92,7 @@ describe('Board card layout', () => { describe('mouse events', () => { it('sets showDetail to true on mousedown', async () => { + createStore(); mountComponent(); wrapper.trigger('mousedown'); @@ -83,6 +102,7 @@ describe('Board card layout', () => { }); it('sets showDetail to false on mousemove', async () => { + createStore(); mountComponent(); wrapper.trigger('mousedown'); await wrapper.vm.$nextTick(); @@ -91,5 +111,49 @@ describe('Board card layout', () => { await wrapper.vm.$nextTick(); expect(wrapper.vm.showDetail).toBe(false); }); + + it("calls 'setActiveId' when 'graphqlBoardLists' feature flag is turned on", async () => { + const setActiveId = jest.fn(); + createStore({ + actions: { + setActiveId, + }, + }); + mountComponent({ + provide: { + glFeatures: { graphqlBoardLists: true }, + }, + }); + + wrapper.trigger('mouseup'); + await wrapper.vm.$nextTick(); + + expect(setActiveId).toHaveBeenCalledTimes(1); + expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), { + id: list.issues[0].id, + sidebarType: ISSUABLE, + }); + }); + + it("calls 'setActiveId' when epic swimlanes is active", async () => { + const setActiveId = jest.fn(); + const isSwimlanesOn = () => true; + createStore({ + getters: { isSwimlanesOn }, + actions: { + setActiveId, + }, + }); + mountComponent(); + + wrapper.trigger('mouseup'); + await wrapper.vm.$nextTick(); + + expect(setActiveId).toHaveBeenCalledTimes(1); + expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), { + id: list.issues[0].id, + sidebarType: ISSUABLE, + }); + }); }); }); diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js index 5e23c781eae..1084009caad 100644 --- a/spec/frontend/boards/components/board_card_spec.js +++ b/spec/frontend/boards/components/board_card_spec.js @@ -29,7 +29,7 @@ describe('BoardCard', () => { const findUserAvatarLink = () => wrapper.find(userAvatarLink); // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized - const mountComponent = propsData => { + const mountComponent = (propsData) => { wrapper = mount(BoardCard, { stubs: { issueCardInner, @@ -45,6 +45,7 @@ describe('BoardCard', () => { provide: { groupId: null, rootPath: '/', + scopedLabelsAvailable: false, }, }); }; @@ -133,9 +134,7 @@ describe('BoardCard', () => { it('does not set detail issue if link is clicked', () => { mountComponent(); - findIssueCardInner() - .find('a') - .trigger('mouseup'); + findIssueCardInner().find('a').trigger('mouseup'); expect(boardsStore.detail.issue).toEqual({}); }); diff --git a/spec/frontend/boards/components/board_column_new_spec.js b/spec/frontend/boards/components/board_column_deprecated_spec.js index 81c0e60f931..a703caca4eb 100644 --- a/spec/frontend/boards/components/board_column_new_spec.js +++ b/spec/frontend/boards/components/board_column_deprecated_spec.js @@ -1,40 +1,65 @@ +import Vue from 'vue'; import { shallowMount } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'helpers/test_constants'; import { listObj } from 'jest/boards/mock_data'; -import BoardColumn from '~/boards/components/board_column_new.vue'; +import Board from '~/boards/components/board_column_deprecated.vue'; +import List from '~/boards/models/list'; import { ListType } from '~/boards/constants'; -import { createStore } from '~/boards/stores'; +import axios from '~/lib/utils/axios_utils'; describe('Board Column Component', () => { let wrapper; - let store; + let axiosMock; + + beforeEach(() => { + window.gon = {}; + axiosMock = new AxiosMockAdapter(axios); + axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] }); + }); afterEach(() => { + axiosMock.restore(); + wrapper.destroy(); - wrapper = null; + + localStorage.clear(); }); - const createComponent = ({ listType = ListType.backlog, collapsed = false } = {}) => { + const createComponent = ({ + listType = ListType.backlog, + collapsed = false, + withLocalStorage = true, + } = {}) => { const boardId = '1'; const listMock = { ...listObj, - listType, + list_type: listType, collapsed, }; if (listType === ListType.assignee) { delete listMock.label; - listMock.assignee = {}; + listMock.user = {}; } - store = createStore(); + // Making List reactive + const list = Vue.observable(new List(listMock)); - wrapper = shallowMount(BoardColumn, { - store, + if (withLocalStorage) { + localStorage.setItem( + `boards.${boardId}.${list.type}.${list.id}.expanded`, + (!collapsed).toString(), + ); + } + + wrapper = shallowMount(Board, { propsData: { + boardId, disabled: false, - list: listMock, + list, }, provide: { boardId, @@ -57,7 +82,7 @@ describe('Board Column Component', () => { it('has class is-collapsed when list is collapsed', () => { createComponent({ collapsed: false }); - expect(isCollapsed()).toBe(false); + expect(wrapper.vm.list.isExpanded).toBe(true); }); it('does not have class is-collapsed when list is expanded', () => { diff --git a/spec/frontend/boards/components/board_column_spec.js b/spec/frontend/boards/components/board_column_spec.js index ba11225676b..1dcdad2b492 100644 --- a/spec/frontend/boards/components/board_column_spec.js +++ b/spec/frontend/boards/components/board_column_spec.js @@ -1,65 +1,40 @@ -import Vue from 'vue'; import { shallowMount } from '@vue/test-utils'; -import AxiosMockAdapter from 'axios-mock-adapter'; -import { TEST_HOST } from 'helpers/test_constants'; import { listObj } from 'jest/boards/mock_data'; -import Board from '~/boards/components/board_column.vue'; -import List from '~/boards/models/list'; +import BoardColumn from '~/boards/components/board_column.vue'; import { ListType } from '~/boards/constants'; -import axios from '~/lib/utils/axios_utils'; +import { createStore } from '~/boards/stores'; describe('Board Column Component', () => { let wrapper; - let axiosMock; - - beforeEach(() => { - window.gon = {}; - axiosMock = new AxiosMockAdapter(axios); - axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] }); - }); + let store; afterEach(() => { - axiosMock.restore(); - wrapper.destroy(); - - localStorage.clear(); + wrapper = null; }); - const createComponent = ({ - listType = ListType.backlog, - collapsed = false, - withLocalStorage = true, - } = {}) => { + const createComponent = ({ listType = ListType.backlog, collapsed = false } = {}) => { const boardId = '1'; const listMock = { ...listObj, - list_type: listType, + listType, collapsed, }; if (listType === ListType.assignee) { delete listMock.label; - listMock.user = {}; + listMock.assignee = {}; } - // Making List reactive - const list = Vue.observable(new List(listMock)); + store = createStore(); - if (withLocalStorage) { - localStorage.setItem( - `boards.${boardId}.${list.type}.${list.id}.expanded`, - (!collapsed).toString(), - ); - } - - wrapper = shallowMount(Board, { + wrapper = shallowMount(BoardColumn, { + store, propsData: { - boardId, disabled: false, - list, + list: listMock, }, provide: { boardId, @@ -82,7 +57,7 @@ describe('Board Column Component', () => { it('has class is-collapsed when list is collapsed', () => { createComponent({ collapsed: false }); - expect(wrapper.vm.list.isExpanded).toBe(true); + expect(isCollapsed()).toBe(false); }); it('does not have class is-collapsed when list is expanded', () => { diff --git a/spec/frontend/boards/components/board_configuration_options_spec.js b/spec/frontend/boards/components/board_configuration_options_spec.js index e9a1cb6a4e8..d9614c254e2 100644 --- a/spec/frontend/boards/components/board_configuration_options_spec.js +++ b/spec/frontend/boards/components/board_configuration_options_spec.js @@ -3,38 +3,30 @@ import BoardConfigurationOptions from '~/boards/components/board_configuration_o describe('BoardConfigurationOptions', () => { let wrapper; - const board = { hide_backlog_list: false, hide_closed_list: false }; const defaultProps = { - currentBoard: board, - board, - isNewForm: false, + hideBacklogList: false, + hideClosedList: false, }; - const createComponent = () => { + const createComponent = (props = {}) => { wrapper = shallowMount(BoardConfigurationOptions, { - propsData: { ...defaultProps }, + propsData: { ...defaultProps, ...props }, }); }; - beforeEach(() => { - createComponent(); - }); - afterEach(() => { wrapper.destroy(); }); - const backlogListCheckbox = el => el.find('[data-testid="backlog-list-checkbox"]'); - const closedListCheckbox = el => el.find('[data-testid="closed-list-checkbox"]'); + const backlogListCheckbox = () => wrapper.find('[data-testid="backlog-list-checkbox"]'); + const closedListCheckbox = () => wrapper.find('[data-testid="closed-list-checkbox"]'); const checkboxAssert = (backlogCheckbox, closedCheckbox) => { - expect(backlogListCheckbox(wrapper).attributes('checked')).toEqual( + expect(backlogListCheckbox().attributes('checked')).toEqual( backlogCheckbox ? undefined : 'true', ); - expect(closedListCheckbox(wrapper).attributes('checked')).toEqual( - closedCheckbox ? undefined : 'true', - ); + expect(closedListCheckbox().attributes('checked')).toEqual(closedCheckbox ? undefined : 'true'); }; it.each` @@ -45,15 +37,28 @@ describe('BoardConfigurationOptions', () => { ${false} | ${false} `( 'renders two checkbox when one is $backlogCheckboxValue and other is $closedCheckboxValue', - async ({ backlogCheckboxValue, closedCheckboxValue }) => { - await wrapper.setData({ + ({ backlogCheckboxValue, closedCheckboxValue }) => { + createComponent({ hideBacklogList: backlogCheckboxValue, hideClosedList: closedCheckboxValue, }); - - return wrapper.vm.$nextTick().then(() => { - checkboxAssert(backlogCheckboxValue, closedCheckboxValue); - }); + checkboxAssert(backlogCheckboxValue, closedCheckboxValue); }, ); + + it('emits a correct value on backlog checkbox change', () => { + createComponent(); + + backlogListCheckbox().vm.$emit('change'); + + expect(wrapper.emitted('update:hideBacklogList')).toEqual([[true]]); + }); + + it('emits a correct value on closed checkbox change', () => { + createComponent(); + + closedListCheckbox().vm.$emit('change'); + + expect(wrapper.emitted('update:hideClosedList')).toEqual([[true]]); + }); }); diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js index 291013c561e..98be02d7dbf 100644 --- a/spec/frontend/boards/components/board_content_spec.js +++ b/spec/frontend/boards/components/board_content_spec.js @@ -4,7 +4,7 @@ import { GlAlert } from '@gitlab/ui'; import Draggable from 'vuedraggable'; import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue'; import getters from 'ee_else_ce/boards/stores/getters'; -import BoardColumn from '~/boards/components/board_column.vue'; +import BoardColumnDeprecated from '~/boards/components/board_column_deprecated.vue'; import { mockLists, mockListsWithModel } from '../mock_data'; import BoardContent from '~/boards/components/board_content.vue'; @@ -17,6 +17,7 @@ const actions = { describe('BoardContent', () => { let wrapper; + window.gon = {}; const defaultState = { isShowingEpicsSwimlanes: false, @@ -56,10 +57,12 @@ describe('BoardContent', () => { wrapper.destroy(); }); - it('renders a BoardColumn component per list', () => { + it('renders a BoardColumnDeprecated component per list', () => { createComponent(); - expect(wrapper.findAll(BoardColumn)).toHaveLength(mockLists.length); + expect(wrapper.findAllComponents(BoardColumnDeprecated)).toHaveLength( + mockListsWithModel.length, + ); }); it('does not display EpicsSwimlanes component', () => { @@ -70,6 +73,13 @@ describe('BoardContent', () => { }); describe('graphqlBoardLists feature flag enabled', () => { + beforeEach(() => { + createComponent({ graphqlBoardListsEnabled: true }); + gon.features = { + graphqlBoardLists: true, + }; + }); + describe('can admin list', () => { beforeEach(() => { createComponent({ graphqlBoardListsEnabled: true, props: { canAdminList: true } }); @@ -85,7 +95,7 @@ describe('BoardContent', () => { createComponent({ graphqlBoardListsEnabled: true, props: { canAdminList: false } }); }); - it('renders draggable component', () => { + it('does not render draggable component', () => { expect(wrapper.find(Draggable).exists()).toBe(false); }); }); diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index 3b15cbb6b7e..c34987a55de 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -1,20 +1,22 @@ import { shallowMount } from '@vue/test-utils'; -import AxiosMockAdapter from 'axios-mock-adapter'; -import { TEST_HOST } from 'jest/helpers/test_constants'; +import { TEST_HOST } from 'helpers/test_constants'; import { GlModal } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; -import axios from '~/lib/utils/axios_utils'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; import boardsStore from '~/boards/stores/boards_store'; import BoardForm from '~/boards/components/board_form.vue'; -import BoardConfigurationOptions from '~/boards/components/board_configuration_options.vue'; -import createBoardMutation from '~/boards/graphql/board.mutation.graphql'; +import updateBoardMutation from '~/boards/graphql/board_update.mutation.graphql'; +import createBoardMutation from '~/boards/graphql/board_create.mutation.graphql'; +import destroyBoardMutation from '~/boards/graphql/board_destroy.mutation.graphql'; jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn().mockName('visitUrlMock'), + stripFinalUrlSegment: jest.requireActual('~/lib/utils/url_utility').stripFinalUrlSegment, })); +jest.mock('~/flash'); const currentBoard = { id: 1, @@ -28,18 +30,6 @@ const currentBoard = { hide_closed_list: false, }; -const boardDefaults = { - id: false, - name: '', - labels: [], - milestone_id: undefined, - assignee: {}, - assignee_id: undefined, - weight: null, - hide_backlog_list: false, - hide_closed_list: false, -}; - const defaultProps = { canAdminBoard: false, labelsPath: `${TEST_HOST}/labels/path`, @@ -47,22 +37,15 @@ const defaultProps = { currentBoard, }; -const endpoints = { - boardsEndpoint: 'test-endpoint', -}; - -const mutate = jest.fn().mockResolvedValue({}); - describe('BoardForm', () => { let wrapper; - let axiosMock; + let mutate; const findModal = () => wrapper.find(GlModal); const findModalActionPrimary = () => findModal().props('actionPrimary'); const findForm = () => wrapper.find('[data-testid="board-form"]'); const findFormWrapper = () => wrapper.find('[data-testid="board-form-wrapper"]'); const findDeleteConfirmation = () => wrapper.find('[data-testid="delete-confirmation-message"]'); - const findConfigurationOptions = () => wrapper.find(BoardConfigurationOptions); const findInput = () => wrapper.find('#board-new-name'); const createComponent = (props, data) => { @@ -74,26 +57,26 @@ describe('BoardForm', () => { }; }, provide: { - endpoints, + rootPath: 'root', }, mocks: { $apollo: { mutate, }, }, - attachToDocument: true, + attachTo: document.body, }); }; beforeEach(() => { - axiosMock = new AxiosMockAdapter(axios); + delete window.location; }); afterEach(() => { wrapper.destroy(); wrapper = null; - axiosMock.restore(); boardsStore.state.currentPage = null; + mutate = null; }); describe('when user can not admin the board', () => { @@ -145,7 +128,7 @@ describe('BoardForm', () => { }); it('clears the form', () => { - expect(findConfigurationOptions().props('board')).toEqual(boardDefaults); + expect(findInput().element.value).toBe(''); }); it('shows a correct title about creating a board', () => { @@ -164,16 +147,21 @@ describe('BoardForm', () => { it('renders form wrapper', () => { expect(findFormWrapper().exists()).toBe(true); }); - - it('passes a true isNewForm prop to BoardConfigurationOptions component', () => { - expect(findConfigurationOptions().props('isNewForm')).toBe(true); - }); }); describe('when submitting a create event', () => { + const fillForm = () => { + findInput().value = 'Test name'; + findInput().trigger('input'); + findInput().trigger('keyup.enter', { metaKey: true }); + }; + beforeEach(() => { - const url = `${endpoints.boardsEndpoint}.json`; - axiosMock.onPost(url).reply(200, { id: '2', board_path: 'new path' }); + mutate = jest.fn().mockResolvedValue({ + data: { + createBoard: { board: { id: 'gid://gitlab/Board/123', webPath: 'test-path' } }, + }, + }); }); it('does not call API if board name is empty', async () => { @@ -185,28 +173,37 @@ describe('BoardForm', () => { expect(mutate).not.toHaveBeenCalled(); }); - it('calls REST and GraphQL API and redirects to correct page', async () => { + it('calls a correct GraphQL mutation and redirects to correct page from existing board', async () => { createComponent({ canAdminBoard: true }); - - findInput().value = 'Test name'; - findInput().trigger('input'); - findInput().trigger('keyup.enter', { metaKey: true }); + fillForm(); await waitForPromises(); - expect(axiosMock.history.post[0].data).toBe( - JSON.stringify({ board: { ...boardDefaults, name: 'test', label_ids: [''] } }), - ); - expect(mutate).toHaveBeenCalledWith({ mutation: createBoardMutation, variables: { - id: 'gid://gitlab/Board/2', + input: expect.objectContaining({ + name: 'test', + }), }, }); await waitForPromises(); - expect(visitUrl).toHaveBeenCalledWith('new path'); + expect(visitUrl).toHaveBeenCalledWith('test-path'); + }); + + it('shows an error flash if GraphQL mutation fails', async () => { + mutate = jest.fn().mockRejectedValue('Houston, we have a problem'); + createComponent({ canAdminBoard: true }); + fillForm(); + + await waitForPromises(); + + expect(mutate).toHaveBeenCalled(); + + await waitForPromises(); + expect(visitUrl).not.toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalled(); }); }); }); @@ -222,7 +219,7 @@ describe('BoardForm', () => { }); it('clears the form', () => { - expect(findConfigurationOptions().props('board')).toEqual(currentBoard); + expect(findInput().element.value).toEqual(currentBoard.name); }); it('shows a correct title about creating a board', () => { @@ -241,36 +238,121 @@ describe('BoardForm', () => { it('renders form wrapper', () => { expect(findFormWrapper().exists()).toBe(true); }); + }); - it('passes a false isNewForm prop to BoardConfigurationOptions component', () => { - expect(findConfigurationOptions().props('isNewForm')).toBe(false); + it('calls GraphQL mutation with correct parameters when issues are not grouped', async () => { + mutate = jest.fn().mockResolvedValue({ + data: { + updateBoard: { board: { id: 'gid://gitlab/Board/321', webPath: 'test-path' } }, + }, }); - }); + window.location = new URL('https://test/boards/1'); + createComponent({ canAdminBoard: true }); - describe('when submitting an update event', () => { - beforeEach(() => { - const url = endpoints.boardsEndpoint; - axiosMock.onPut(url).reply(200, { board_path: 'new path' }); + findInput().trigger('keyup.enter', { metaKey: true }); + + await waitForPromises(); + + expect(mutate).toHaveBeenCalledWith({ + mutation: updateBoardMutation, + variables: { + input: expect.objectContaining({ + id: `gid://gitlab/Board/${currentBoard.id}`, + }), + }, }); - it('calls REST and GraphQL API with correct parameters', async () => { - createComponent({ canAdminBoard: true }); + await waitForPromises(); + expect(visitUrl).toHaveBeenCalledWith('test-path'); + }); - findInput().trigger('keyup.enter', { metaKey: true }); + it('calls GraphQL mutation with correct parameters when issues are grouped by epic', async () => { + mutate = jest.fn().mockResolvedValue({ + data: { + updateBoard: { board: { id: 'gid://gitlab/Board/321', webPath: 'test-path' } }, + }, + }); + window.location = new URL('https://test/boards/1?group_by=epic'); + createComponent({ canAdminBoard: true }); - await waitForPromises(); + findInput().trigger('keyup.enter', { metaKey: true }); - expect(axiosMock.history.put[0].data).toBe( - JSON.stringify({ board: { ...currentBoard, label_ids: [''] } }), - ); + await waitForPromises(); - expect(mutate).toHaveBeenCalledWith({ - mutation: createBoardMutation, - variables: { + expect(mutate).toHaveBeenCalledWith({ + mutation: updateBoardMutation, + variables: { + input: expect.objectContaining({ id: `gid://gitlab/Board/${currentBoard.id}`, - }, - }); + }), + }, }); + + await waitForPromises(); + expect(visitUrl).toHaveBeenCalledWith('test-path?group_by=epic'); + }); + + it('shows an error flash if GraphQL mutation fails', async () => { + mutate = jest.fn().mockRejectedValue('Houston, we have a problem'); + createComponent({ canAdminBoard: true }); + findInput().trigger('keyup.enter', { metaKey: true }); + + await waitForPromises(); + + expect(mutate).toHaveBeenCalled(); + + await waitForPromises(); + expect(visitUrl).not.toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalled(); + }); + }); + + describe('when deleting a board', () => { + beforeEach(() => { + boardsStore.state.currentPage = 'delete'; + }); + + it('passes correct primary action text and variant', () => { + createComponent({ canAdminBoard: true }); + expect(findModalActionPrimary().text).toBe('Delete'); + expect(findModalActionPrimary().attributes[0].variant).toBe('danger'); + }); + + it('renders delete confirmation message', () => { + createComponent({ canAdminBoard: true }); + expect(findDeleteConfirmation().exists()).toBe(true); + }); + + it('calls a correct GraphQL mutation and redirects to correct page after deleting board', async () => { + mutate = jest.fn().mockResolvedValue({}); + createComponent({ canAdminBoard: true }); + findModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(mutate).toHaveBeenCalledWith({ + mutation: destroyBoardMutation, + variables: { + id: 'gid://gitlab/Board/1', + }, + }); + + await waitForPromises(); + expect(visitUrl).toHaveBeenCalledWith('root'); + }); + + it('shows an error flash if GraphQL mutation fails', async () => { + mutate = jest.fn().mockRejectedValue('Houston, we have a problem'); + createComponent({ canAdminBoard: true }); + findModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(mutate).toHaveBeenCalled(); + + await waitForPromises(); + expect(visitUrl).not.toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/boards/components/board_list_header_new_spec.js b/spec/frontend/boards/components/board_list_header_deprecated_spec.js index 7428dfae83f..6207724e6a9 100644 --- a/spec/frontend/boards/components/board_list_header_new_spec.js +++ b/spec/frontend/boards/components/board_list_header_deprecated_spec.js @@ -1,23 +1,28 @@ -import Vuex from 'vuex'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; - -import { mockLabelList } from 'jest/boards/mock_data'; -import BoardListHeader from '~/boards/components/board_list_header_new.vue'; +import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; + +import { TEST_HOST } from 'helpers/test_constants'; +import { listObj } from 'jest/boards/mock_data'; +import BoardListHeader from '~/boards/components/board_list_header_deprecated.vue'; +import List from '~/boards/models/list'; import { ListType } from '~/boards/constants'; - -const localVue = createLocalVue(); - -localVue.use(Vuex); +import axios from '~/lib/utils/axios_utils'; describe('Board List Header Component', () => { let wrapper; - let store; + let axiosMock; - const updateListSpy = jest.fn(); + beforeEach(() => { + window.gon = {}; + axiosMock = new AxiosMockAdapter(axios); + axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] }); + }); afterEach(() => { + axiosMock.restore(); + wrapper.destroy(); - wrapper = null; localStorage.clear(); }); @@ -26,76 +31,65 @@ describe('Board List Header Component', () => { listType = ListType.backlog, collapsed = false, withLocalStorage = true, - currentUserId = null, } = {}) => { const boardId = '1'; const listMock = { - ...mockLabelList, - listType, + ...listObj, + list_type: listType, collapsed, }; if (listType === ListType.assignee) { delete listMock.label; - listMock.assignee = {}; + listMock.user = {}; } + // Making List reactive + const list = Vue.observable(new List(listMock)); + if (withLocalStorage) { localStorage.setItem( - `boards.${boardId}.${listMock.listType}.${listMock.id}.expanded`, + `boards.${boardId}.${list.type}.${list.id}.expanded`, (!collapsed).toString(), ); } - store = new Vuex.Store({ - state: {}, - actions: { updateList: updateListSpy }, - getters: {}, - }); - wrapper = shallowMount(BoardListHeader, { - store, - localVue, propsData: { disabled: false, - list: listMock, + list, }, provide: { boardId, - weightFeatureAvailable: false, - currentUserId, }, }); }; - const isCollapsed = () => wrapper.vm.list.collapsed; - const isExpanded = () => !isCollapsed; + const isCollapsed = () => !wrapper.props().list.isExpanded; + const isExpanded = () => wrapper.vm.list.isExpanded; const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' }); - const findTitle = () => wrapper.find('.board-title'); const findCaret = () => wrapper.find('.board-title-caret'); describe('Add issue button', () => { const hasNoAddButton = [ListType.closed]; const hasAddButton = [ListType.backlog, ListType.label, ListType.milestone, ListType.assignee]; - it.each(hasNoAddButton)('does not render when List Type is `%s`', listType => { + it.each(hasNoAddButton)('does not render when List Type is `%s`', (listType) => { createComponent({ listType }); expect(findAddIssueButton().exists()).toBe(false); }); - it.each(hasAddButton)('does render when List Type is `%s`', listType => { + it.each(hasAddButton)('does render when List Type is `%s`', (listType) => { createComponent({ listType }); expect(findAddIssueButton().exists()).toBe(true); }); it('has a test for each list type', () => { - createComponent(); - - Object.values(ListType).forEach(value => { + Object.values(ListType).forEach((value) => { expect([...hasAddButton, ...hasNoAddButton]).toContain(value); }); }); @@ -108,80 +102,64 @@ describe('Board List Header Component', () => { }); describe('expanding / collapsing the column', () => { - it('does not collapse when clicking the header', async () => { + it('does not collapse when clicking the header', () => { createComponent(); expect(isCollapsed()).toBe(false); - wrapper.find('[data-testid="board-list-header"]').trigger('click'); - await wrapper.vm.$nextTick(); - - expect(isCollapsed()).toBe(false); + return wrapper.vm.$nextTick().then(() => { + expect(isCollapsed()).toBe(false); + }); }); - it('collapses expanded Column when clicking the collapse icon', async () => { + it('collapses expanded Column when clicking the collapse icon', () => { createComponent(); - expect(isCollapsed()).toBe(false); - + expect(isExpanded()).toBe(true); findCaret().vm.$emit('click'); - await wrapper.vm.$nextTick(); - - expect(isCollapsed()).toBe(true); + return wrapper.vm.$nextTick().then(() => { + expect(isCollapsed()).toBe(true); + }); }); - it('expands collapsed Column when clicking the expand icon', async () => { + it('expands collapsed Column when clicking the expand icon', () => { createComponent({ collapsed: true }); expect(isCollapsed()).toBe(true); - findCaret().vm.$emit('click'); - await wrapper.vm.$nextTick(); - - expect(isCollapsed()).toBe(false); + return wrapper.vm.$nextTick().then(() => { + expect(isCollapsed()).toBe(false); + }); }); - it("when logged in it calls list update and doesn't set localStorage", async () => { - createComponent({ withLocalStorage: false, currentUserId: 1 }); - - findCaret().vm.$emit('click'); - await wrapper.vm.$nextTick(); - - expect(updateListSpy).toHaveBeenCalledTimes(1); - expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null); - }); + it("when logged in it calls list update and doesn't set localStorage", () => { + jest.spyOn(List.prototype, 'update'); + window.gon.current_user_id = 1; - it("when logged out it doesn't call list update and sets localStorage", async () => { - createComponent(); + createComponent({ withLocalStorage: false }); findCaret().vm.$emit('click'); - await wrapper.vm.$nextTick(); - expect(updateListSpy).not.toHaveBeenCalled(); - expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded())); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1); + expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null); + }); }); - }); - describe('user can drag', () => { - const cannotDragList = [ListType.backlog, ListType.closed]; - const canDragList = [ListType.label, ListType.milestone, ListType.assignee]; + it("when logged out it doesn't call list update and sets localStorage", () => { + jest.spyOn(List.prototype, 'update'); - it.each(cannotDragList)( - 'does not have user-can-drag-class so user cannot drag list', - listType => { - createComponent({ listType }); - - expect(findTitle().classes()).not.toContain('user-can-drag'); - }, - ); + createComponent(); - it.each(canDragList)('has user-can-drag-class so user can drag list', listType => { - createComponent({ listType }); + findCaret().vm.$emit('click'); - expect(findTitle().classes()).toContain('user-can-drag'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.list.update).not.toHaveBeenCalled(); + expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded())); + }); }); }); }); diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js index 656a503bb86..357d05ced02 100644 --- a/spec/frontend/boards/components/board_list_header_spec.js +++ b/spec/frontend/boards/components/board_list_header_spec.js @@ -1,28 +1,23 @@ -import Vue from 'vue'; -import { shallowMount } from '@vue/test-utils'; -import AxiosMockAdapter from 'axios-mock-adapter'; +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { TEST_HOST } from 'helpers/test_constants'; -import { listObj } from 'jest/boards/mock_data'; +import { mockLabelList } from 'jest/boards/mock_data'; import BoardListHeader from '~/boards/components/board_list_header.vue'; -import List from '~/boards/models/list'; import { ListType } from '~/boards/constants'; -import axios from '~/lib/utils/axios_utils'; + +const localVue = createLocalVue(); + +localVue.use(Vuex); describe('Board List Header Component', () => { let wrapper; - let axiosMock; + let store; - beforeEach(() => { - window.gon = {}; - axiosMock = new AxiosMockAdapter(axios); - axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] }); - }); + const updateListSpy = jest.fn(); afterEach(() => { - axiosMock.restore(); - wrapper.destroy(); + wrapper = null; localStorage.clear(); }); @@ -31,65 +26,76 @@ describe('Board List Header Component', () => { listType = ListType.backlog, collapsed = false, withLocalStorage = true, + currentUserId = null, } = {}) => { const boardId = '1'; const listMock = { - ...listObj, - list_type: listType, + ...mockLabelList, + listType, collapsed, }; if (listType === ListType.assignee) { delete listMock.label; - listMock.user = {}; + listMock.assignee = {}; } - // Making List reactive - const list = Vue.observable(new List(listMock)); - if (withLocalStorage) { localStorage.setItem( - `boards.${boardId}.${list.type}.${list.id}.expanded`, + `boards.${boardId}.${listMock.listType}.${listMock.id}.expanded`, (!collapsed).toString(), ); } + store = new Vuex.Store({ + state: {}, + actions: { updateList: updateListSpy }, + getters: {}, + }); + wrapper = shallowMount(BoardListHeader, { + store, + localVue, propsData: { disabled: false, - list, + list: listMock, }, provide: { boardId, + weightFeatureAvailable: false, + currentUserId, }, }); }; - const isCollapsed = () => !wrapper.props().list.isExpanded; - const isExpanded = () => wrapper.vm.list.isExpanded; + const isCollapsed = () => wrapper.vm.list.collapsed; + const isExpanded = () => !isCollapsed; const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' }); + const findTitle = () => wrapper.find('.board-title'); const findCaret = () => wrapper.find('.board-title-caret'); describe('Add issue button', () => { const hasNoAddButton = [ListType.closed]; const hasAddButton = [ListType.backlog, ListType.label, ListType.milestone, ListType.assignee]; - it.each(hasNoAddButton)('does not render when List Type is `%s`', listType => { + it.each(hasNoAddButton)('does not render when List Type is `%s`', (listType) => { createComponent({ listType }); expect(findAddIssueButton().exists()).toBe(false); }); - it.each(hasAddButton)('does render when List Type is `%s`', listType => { + it.each(hasAddButton)('does render when List Type is `%s`', (listType) => { createComponent({ listType }); expect(findAddIssueButton().exists()).toBe(true); }); it('has a test for each list type', () => { - Object.values(ListType).forEach(value => { + createComponent(); + + Object.values(ListType).forEach((value) => { expect([...hasAddButton, ...hasNoAddButton]).toContain(value); }); }); @@ -102,64 +108,80 @@ describe('Board List Header Component', () => { }); describe('expanding / collapsing the column', () => { - it('does not collapse when clicking the header', () => { + it('does not collapse when clicking the header', async () => { createComponent(); expect(isCollapsed()).toBe(false); + wrapper.find('[data-testid="board-list-header"]').trigger('click'); - return wrapper.vm.$nextTick().then(() => { - expect(isCollapsed()).toBe(false); - }); + await wrapper.vm.$nextTick(); + + expect(isCollapsed()).toBe(false); }); - it('collapses expanded Column when clicking the collapse icon', () => { + it('collapses expanded Column when clicking the collapse icon', async () => { createComponent(); - expect(isExpanded()).toBe(true); + expect(isCollapsed()).toBe(false); + findCaret().vm.$emit('click'); - return wrapper.vm.$nextTick().then(() => { - expect(isCollapsed()).toBe(true); - }); + await wrapper.vm.$nextTick(); + + expect(isCollapsed()).toBe(true); }); - it('expands collapsed Column when clicking the expand icon', () => { + it('expands collapsed Column when clicking the expand icon', async () => { createComponent({ collapsed: true }); expect(isCollapsed()).toBe(true); + findCaret().vm.$emit('click'); - return wrapper.vm.$nextTick().then(() => { - expect(isCollapsed()).toBe(false); - }); - }); + await wrapper.vm.$nextTick(); - it("when logged in it calls list update and doesn't set localStorage", () => { - jest.spyOn(List.prototype, 'update'); - window.gon.current_user_id = 1; + expect(isCollapsed()).toBe(false); + }); - createComponent({ withLocalStorage: false }); + it("when logged in it calls list update and doesn't set localStorage", async () => { + createComponent({ withLocalStorage: false, currentUserId: 1 }); findCaret().vm.$emit('click'); + await wrapper.vm.$nextTick(); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1); - expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null); - }); + expect(updateListSpy).toHaveBeenCalledTimes(1); + expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null); }); - it("when logged out it doesn't call list update and sets localStorage", () => { - jest.spyOn(List.prototype, 'update'); - + it("when logged out it doesn't call list update and sets localStorage", async () => { createComponent(); findCaret().vm.$emit('click'); + await wrapper.vm.$nextTick(); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.list.update).not.toHaveBeenCalled(); - expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded())); - }); + expect(updateListSpy).not.toHaveBeenCalled(); + expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded())); + }); + }); + + describe('user can drag', () => { + const cannotDragList = [ListType.backlog, ListType.closed]; + const canDragList = [ListType.label, ListType.milestone, ListType.assignee]; + + it.each(cannotDragList)( + 'does not have user-can-drag-class so user cannot drag list', + (listType) => { + createComponent({ listType }); + + expect(findTitle().classes()).not.toContain('user-can-drag'); + }, + ); + + it.each(canDragList)('has user-can-drag-class so user can drag list', (listType) => { + createComponent({ listType }); + + expect(findTitle().classes()).toContain('user-can-drag'); }); }); }); diff --git a/spec/frontend/boards/components/board_new_issue_new_spec.js b/spec/frontend/boards/components/board_new_issue_spec.js index ee1c4f31cf0..5a01221a5be 100644 --- a/spec/frontend/boards/components/board_new_issue_new_spec.js +++ b/spec/frontend/boards/components/board_new_issue_spec.js @@ -1,9 +1,9 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import BoardNewIssue from '~/boards/components/board_new_issue_new.vue'; +import BoardNewIssue from '~/boards/components/board_new_issue.vue'; import '~/boards/models/list'; -import { mockList } from '../mock_data'; +import { mockList, mockGroupProjects } from '../mock_data'; const localVue = createLocalVue(); @@ -29,7 +29,7 @@ describe('Issue boards new issue form', () => { beforeEach(() => { const store = new Vuex.Store({ - state: {}, + state: { selectedProject: mockGroupProjects[0] }, actions: { addListNewIssue: addListNewIssuesSpy }, getters: {}, }); diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js index db3c8c22950..81575bf486a 100644 --- a/spec/frontend/boards/components/boards_selector_spec.js +++ b/spec/frontend/boards/components/boards_selector_spec.js @@ -26,7 +26,7 @@ describe('BoardsSelector', () => { const boards = boardGenerator(20); const recentBoards = boardGenerator(5); - const fillSearchBox = filterTerm => { + const fillSearchBox = (filterTerm) => { const searchBox = wrapper.find({ ref: 'searchBox' }); const searchBoxInput = searchBox.find('input'); searchBoxInput.setValue(filterTerm); @@ -59,7 +59,7 @@ describe('BoardsSelector', () => { data: { group: { boards: { - edges: boards.map(board => ({ node: board })), + edges: boards.map((board) => ({ node: board })), }, }, }, @@ -94,7 +94,7 @@ describe('BoardsSelector', () => { weights: [], }, mocks: { $apollo }, - attachToDocument: true, + attachTo: document.body, }); wrapper.vm.$apollo.addSmartQuery = jest.fn((_, options) => { @@ -152,7 +152,7 @@ describe('BoardsSelector', () => { it('shows only matching boards when filtering', () => { const filterTerm = 'board1'; - const expectedCount = boards.filter(board => board.name.includes(filterTerm)).length; + const expectedCount = boards.filter((board) => board.name.includes(filterTerm)).length; fillSearchBox(filterTerm); diff --git a/spec/frontend/boards/components/issue_count_spec.js b/spec/frontend/boards/components/issue_count_spec.js index d1ff0bdbf88..f1870e9cc9e 100644 --- a/spec/frontend/boards/components/issue_count_spec.js +++ b/spec/frontend/boards/components/issue_count_spec.js @@ -6,7 +6,7 @@ describe('IssueCount', () => { let maxIssueCount; let issuesSize; - const createComponent = props => { + const createComponent = (props) => { vm = shallowMount(IssueCount, { propsData: props }); }; diff --git a/spec/frontend/boards/components/issue_due_date_spec.js b/spec/frontend/boards/components/issue_due_date_spec.js index 880859287e1..73340c1b96b 100644 --- a/spec/frontend/boards/components/issue_due_date_spec.js +++ b/spec/frontend/boards/components/issue_due_date_spec.js @@ -10,7 +10,7 @@ const createComponent = (dueDate = new Date(), closed = false) => }, }); -const findTime = wrapper => wrapper.find('time'); +const findTime = (wrapper) => wrapper.find('time'); describe('Issue Due Date component', () => { let wrapper; diff --git a/spec/frontend/boards/components/issue_time_estimate_deprecated_spec.js b/spec/frontend/boards/components/issue_time_estimate_deprecated_spec.js new file mode 100644 index 00000000000..fafebaf3a4e --- /dev/null +++ b/spec/frontend/boards/components/issue_time_estimate_deprecated_spec.js @@ -0,0 +1,64 @@ +import { shallowMount } from '@vue/test-utils'; +import IssueTimeEstimate from '~/boards/components/issue_time_estimate_deprecated.vue'; +import boardsStore from '~/boards/stores/boards_store'; + +describe('Issue Time Estimate component', () => { + let wrapper; + + beforeEach(() => { + boardsStore.create(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when limitToHours is false', () => { + beforeEach(() => { + boardsStore.timeTracking.limitToHours = false; + wrapper = shallowMount(IssueTimeEstimate, { + propsData: { + estimate: 374460, + }, + }); + }); + + it('renders the correct time estimate', () => { + expect(wrapper.find('time').text().trim()).toEqual('2w 3d 1m'); + }); + + it('renders expanded time estimate in tooltip', () => { + expect(wrapper.find('.js-issue-time-estimate').text()).toContain('2 weeks 3 days 1 minute'); + }); + + it('prevents tooltip xss', (done) => { + const alertSpy = jest.spyOn(window, 'alert'); + wrapper.setProps({ estimate: 'Foo <script>alert("XSS")</script>' }); + wrapper.vm.$nextTick(() => { + expect(alertSpy).not.toHaveBeenCalled(); + expect(wrapper.find('time').text().trim()).toEqual('0m'); + expect(wrapper.find('.js-issue-time-estimate').text()).toContain('0m'); + done(); + }); + }); + }); + + describe('when limitToHours is true', () => { + beforeEach(() => { + boardsStore.timeTracking.limitToHours = true; + wrapper = shallowMount(IssueTimeEstimate, { + propsData: { + estimate: 374460, + }, + }); + }); + + it('renders the correct time estimate', () => { + expect(wrapper.find('time').text().trim()).toEqual('104h 1m'); + }); + + it('renders expanded time estimate in tooltip', () => { + expect(wrapper.find('.js-issue-time-estimate').text()).toContain('104 hours 1 minute'); + }); + }); +}); diff --git a/spec/frontend/boards/components/issue_time_estimate_spec.js b/spec/frontend/boards/components/issue_time_estimate_spec.js index 162a6df828b..9ac8fae3fcc 100644 --- a/spec/frontend/boards/components/issue_time_estimate_spec.js +++ b/spec/frontend/boards/components/issue_time_estimate_spec.js @@ -1,75 +1,65 @@ +import { config as vueConfig } from 'vue'; import { shallowMount } from '@vue/test-utils'; import IssueTimeEstimate from '~/boards/components/issue_time_estimate.vue'; -import boardsStore from '~/boards/stores/boards_store'; describe('Issue Time Estimate component', () => { let wrapper; - beforeEach(() => { - boardsStore.create(); - }); - afterEach(() => { wrapper.destroy(); }); describe('when limitToHours is false', () => { beforeEach(() => { - boardsStore.timeTracking.limitToHours = false; wrapper = shallowMount(IssueTimeEstimate, { propsData: { estimate: 374460, }, + provide: { + timeTrackingLimitToHours: false, + }, }); }); it('renders the correct time estimate', () => { - expect( - wrapper - .find('time') - .text() - .trim(), - ).toEqual('2w 3d 1m'); + expect(wrapper.find('time').text().trim()).toEqual('2w 3d 1m'); }); it('renders expanded time estimate in tooltip', () => { expect(wrapper.find('.js-issue-time-estimate').text()).toContain('2 weeks 3 days 1 minute'); }); - it('prevents tooltip xss', done => { + it('prevents tooltip xss', async () => { const alertSpy = jest.spyOn(window, 'alert'); - wrapper.setProps({ estimate: 'Foo <script>alert("XSS")</script>' }); - wrapper.vm.$nextTick(() => { - expect(alertSpy).not.toHaveBeenCalled(); - expect( - wrapper - .find('time') - .text() - .trim(), - ).toEqual('0m'); - expect(wrapper.find('.js-issue-time-estimate').text()).toContain('0m'); - done(); - }); + + try { + // This will raise props validating warning by Vue, silencing it + vueConfig.silent = true; + await wrapper.setProps({ estimate: 'Foo <script>alert("XSS")</script>' }); + } finally { + vueConfig.silent = false; + } + + expect(alertSpy).not.toHaveBeenCalled(); + expect(wrapper.find('time').text().trim()).toEqual('0m'); + expect(wrapper.find('.js-issue-time-estimate').text()).toContain('0m'); }); }); describe('when limitToHours is true', () => { beforeEach(() => { - boardsStore.timeTracking.limitToHours = true; wrapper = shallowMount(IssueTimeEstimate, { propsData: { estimate: 374460, }, + provide: { + timeTrackingLimitToHours: true, + }, }); }); it('renders the correct time estimate', () => { - expect( - wrapper - .find('time') - .text() - .trim(), - ).toEqual('104h 1m'); + expect(wrapper.find('time').text().trim()).toEqual('104h 1m'); }); it('renders expanded time estimate in tooltip', () => { diff --git a/spec/frontend/boards/components/sidebar/board_editable_item_spec.js b/spec/frontend/boards/components/sidebar/board_editable_item_spec.js index d7df2ff1563..de414bb929e 100644 --- a/spec/frontend/boards/components/sidebar/board_editable_item_spec.js +++ b/spec/frontend/boards/components/sidebar/board_editable_item_spec.js @@ -33,6 +33,14 @@ describe('boards sidebar remove issue', () => { expect(findTitle().text()).toBe(title); }); + it('renders provided title slot', () => { + const title = 'Sidebar item title on slot'; + const slots = { title: `<strong>${title}</strong>` }; + createComponent({ slots }); + + expect(wrapper.text()).toContain(title); + }); + it('hides edit button, loader and expanded content by default', () => { createComponent(); @@ -74,9 +82,19 @@ describe('boards sidebar remove issue', () => { return wrapper.vm.$nextTick().then(() => { expect(findCollapsed().isVisible()).toBe(false); expect(findExpanded().isVisible()).toBe(true); - expect(findExpanded().text()).toBe('Select item'); }); }); + + it('hides the header while editing if `toggleHeader` is true', async () => { + createComponent({ canUpdate: true, props: { toggleHeader: true } }); + findEditButton().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + expect(findEditButton().isVisible()).toBe(false); + expect(findTitle().isVisible()).toBe(false); + expect(findExpanded().isVisible()).toBe(true); + }); }); describe('collapsing an item by offclicking', () => { @@ -96,12 +114,13 @@ describe('boards sidebar remove issue', () => { expect(findExpanded().isVisible()).toBe(false); }); - it('emits close event', async () => { + it('emits events', async () => { document.body.click(); await wrapper.vm.$nextTick(); - expect(wrapper.emitted().close.length).toBe(1); + expect(wrapper.emitted().close).toHaveLength(1); + expect(wrapper.emitted()['off-click']).toHaveLength(1); }); }); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_issue_title_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_issue_title_spec.js new file mode 100644 index 00000000000..86895c648a4 --- /dev/null +++ b/spec/frontend/boards/components/sidebar/board_sidebar_issue_title_spec.js @@ -0,0 +1,182 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlAlert, GlFormInput, GlForm } from '@gitlab/ui'; +import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue'; +import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; +import createFlash from '~/flash'; +import { createStore } from '~/boards/stores'; + +const TEST_TITLE = 'New issue title'; +const TEST_ISSUE_A = { + id: 'gid://gitlab/Issue/1', + iid: 8, + title: 'Issue 1', + referencePath: 'h/b#1', +}; +const TEST_ISSUE_B = { + id: 'gid://gitlab/Issue/2', + iid: 9, + title: 'Issue 2', + referencePath: 'h/b#2', +}; + +jest.mock('~/flash'); + +describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => { + let wrapper; + let store; + + afterEach(() => { + localStorage.clear(); + wrapper.destroy(); + store = null; + wrapper = null; + }); + + const createWrapper = (issue = TEST_ISSUE_A) => { + store = createStore(); + store.state.issues = { [issue.id]: { ...issue } }; + store.dispatch('setActiveId', { id: issue.id }); + + wrapper = shallowMount(BoardSidebarIssueTitle, { + store, + provide: { + canUpdate: true, + }, + stubs: { + 'board-editable-item': BoardEditableItem, + }, + }); + }; + + const findForm = () => wrapper.find(GlForm); + const findAlert = () => wrapper.find(GlAlert); + const findFormInput = () => wrapper.find(GlFormInput); + const findEditableItem = () => wrapper.find(BoardEditableItem); + const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]'); + const findTitle = () => wrapper.find('[data-testid="issue-title"]'); + const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]'); + + it('renders title and reference', () => { + createWrapper(); + + expect(findTitle().text()).toContain(TEST_ISSUE_A.title); + expect(findCollapsed().text()).toContain(TEST_ISSUE_A.referencePath); + }); + + it('does not render alert', () => { + createWrapper(); + + expect(findAlert().exists()).toBe(false); + }); + + describe('when new title is submitted', () => { + beforeEach(async () => { + createWrapper(); + + jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => { + store.state.issues[TEST_ISSUE_A.id].title = TEST_TITLE; + }); + findFormInput().vm.$emit('input', TEST_TITLE); + findForm().vm.$emit('submit', { preventDefault: () => {} }); + await wrapper.vm.$nextTick(); + }); + + it('collapses sidebar and renders new title', () => { + expect(findCollapsed().isVisible()).toBe(true); + expect(findTitle().text()).toContain(TEST_TITLE); + }); + + it('commits change to the server', () => { + expect(wrapper.vm.setActiveIssueTitle).toHaveBeenCalledWith({ + title: TEST_TITLE, + projectPath: 'h/b', + }); + }); + }); + + describe('when submitting and invalid title', () => { + beforeEach(async () => { + createWrapper(); + + jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => {}); + findFormInput().vm.$emit('input', ''); + findForm().vm.$emit('submit', { preventDefault: () => {} }); + await wrapper.vm.$nextTick(); + }); + + it('commits change to the server', () => { + expect(wrapper.vm.setActiveIssueTitle).not.toHaveBeenCalled(); + }); + }); + + describe('when abandoning the form without saving', () => { + beforeEach(async () => { + createWrapper(); + + wrapper.vm.$refs.sidebarItem.expand(); + findFormInput().vm.$emit('input', TEST_TITLE); + findEditableItem().vm.$emit('off-click'); + await wrapper.vm.$nextTick(); + }); + + it('does not collapses sidebar and shows alert', () => { + expect(findCollapsed().isVisible()).toBe(false); + expect(findAlert().exists()).toBe(true); + expect(localStorage.getItem(`${TEST_ISSUE_A.id}/issue-title-pending-changes`)).toBe( + TEST_TITLE, + ); + }); + }); + + describe('when accessing the form with pending changes', () => { + beforeAll(() => { + localStorage.setItem(`${TEST_ISSUE_A.id}/issue-title-pending-changes`, TEST_TITLE); + + createWrapper(); + }); + + it('sets title, expands item and shows alert', async () => { + expect(wrapper.vm.title).toBe(TEST_TITLE); + expect(findCollapsed().isVisible()).toBe(false); + expect(findAlert().exists()).toBe(true); + }); + }); + + describe('when cancel button is clicked', () => { + beforeEach(async () => { + createWrapper(TEST_ISSUE_B); + + jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => { + store.state.issues[TEST_ISSUE_B.id].title = TEST_TITLE; + }); + findFormInput().vm.$emit('input', TEST_TITLE); + findCancelButton().vm.$emit('click'); + await wrapper.vm.$nextTick(); + }); + + it('collapses sidebar and render former title', () => { + expect(wrapper.vm.setActiveIssueTitle).not.toHaveBeenCalled(); + expect(findCollapsed().isVisible()).toBe(true); + expect(findTitle().text()).toBe(TEST_ISSUE_B.title); + }); + }); + + describe('when the mutation fails', () => { + beforeEach(async () => { + createWrapper(TEST_ISSUE_B); + + jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => { + throw new Error(['failed mutation']); + }); + findFormInput().vm.$emit('input', 'Invalid title'); + findForm().vm.$emit('submit', { preventDefault: () => {} }); + await wrapper.vm.$nextTick(); + }); + + it('collapses sidebar and renders former issue title', () => { + expect(findCollapsed().isVisible()).toBe(true); + expect(findTitle().text()).toContain(TEST_ISSUE_B.title); + expect(createFlash).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js index da000d21f6a..2342caa9dfd 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js @@ -10,8 +10,8 @@ import createFlash from '~/flash'; jest.mock('~/flash'); -const TEST_LABELS_PAYLOAD = TEST_LABELS.map(label => ({ ...label, set: true })); -const TEST_LABELS_TITLES = TEST_LABELS.map(label => label.title); +const TEST_LABELS_PAYLOAD = TEST_LABELS.map((label) => ({ ...label, set: true })); +const TEST_LABELS_TITLES = TEST_LABELS.map((label) => label.title); describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { let wrapper; @@ -37,14 +37,15 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { labelsFilterBasePath: TEST_HOST, }, stubs: { - 'board-editable-item': BoardEditableItem, - 'labels-select': '<div></div>', + BoardEditableItem, + LabelsSelect: true, }, }); }; const findLabelsSelect = () => wrapper.find({ ref: 'labelsSelect' }); - const findLabelsTitles = () => wrapper.findAll(GlLabel).wrappers.map(item => item.props('title')); + const findLabelsTitles = () => + wrapper.findAll(GlLabel).wrappers.map((item) => item.props('title')); const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]'); it('renders "None" when no labels are selected', () => { @@ -76,7 +77,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { it('commits change to the server', () => { expect(wrapper.vm.setActiveIssueLabels).toHaveBeenCalledWith({ - addLabelIds: TEST_LABELS.map(label => label.id), + addLabelIds: TEST_LABELS.map((label) => label.id), projectPath: 'gitlab-org/test-subgroup/gitlab-test', removeLabelIds: [], }); @@ -84,7 +85,10 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { }); describe('when labels are updated over existing labels', () => { - const testLabelsPayload = [{ id: 5, set: true }, { id: 7, set: true }]; + const testLabelsPayload = [ + { id: 5, set: true }, + { id: 7, set: true }, + ]; const expectedLabels = [{ id: 5 }, { id: 7 }]; beforeEach(async () => { diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js index ee54c662167..b1df0f2d771 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js @@ -83,7 +83,7 @@ describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () = }); describe('Board sidebar subscription component `behavior`', () => { - const mockSetActiveIssueSubscribed = subscribedState => { + const mockSetActiveIssueSubscribed = (subscribedState) => { jest.spyOn(wrapper.vm, 'setActiveIssueSubscribed').mockImplementation(async () => { store.commit(types.UPDATE_ISSUE_BY_ID, { issueId: mockActiveIssue.id, diff --git a/spec/frontend/boards/components/sidebar/remove_issue_spec.js b/spec/frontend/boards/components/sidebar/remove_issue_spec.js index a33e4046724..1b7a78e6e58 100644 --- a/spec/frontend/boards/components/sidebar/remove_issue_spec.js +++ b/spec/frontend/boards/components/sidebar/remove_issue_spec.js @@ -8,7 +8,7 @@ describe('boards sidebar remove issue', () => { const findButton = () => wrapper.find(GlButton); - const createComponent = propsData => { + const createComponent = (propsData) => { wrapper = shallowMount(RemoveIssue, { propsData: { issue: {}, diff --git a/spec/frontend/boards/issue_card_spec.js b/spec/frontend/boards/issue_card_deprecated_spec.js index 7e22e9647f0..fd7b0edb97e 100644 --- a/spec/frontend/boards/issue_card_spec.js +++ b/spec/frontend/boards/issue_card_deprecated_spec.js @@ -6,7 +6,7 @@ import '~/boards/models/assignee'; import '~/boards/models/issue'; import '~/boards/models/list'; import { GlLabel } from '@gitlab/ui'; -import IssueCardInner from '~/boards/components/issue_card_inner.vue'; +import IssueCardInner from '~/boards/components/issue_card_inner_deprecated.vue'; import { listObj } from './mock_data'; import store from '~/boards/stores'; @@ -79,7 +79,7 @@ describe('Issue card component', () => { expect(wrapper.find('.issue-blocked-icon').exists()).toBe(false); }); - it('renders confidential icon', done => { + it('renders confidential icon', (done) => { wrapper.setProps({ issue: { ...wrapper.props('issue'), @@ -102,7 +102,7 @@ describe('Issue card component', () => { }); describe('exists', () => { - beforeEach(done => { + beforeEach((done) => { wrapper.setProps({ issue: { ...wrapper.props('issue'), @@ -132,7 +132,7 @@ describe('Issue card component', () => { expect(wrapper.find('.board-card-assignee img').exists()).toBe(true); }); - it('renders the avatar using avatar_url property', done => { + it('renders the avatar using avatar_url property', (done) => { wrapper.props('issue').updateData({ ...wrapper.props('issue'), assignees: [ @@ -156,7 +156,7 @@ describe('Issue card component', () => { }); describe('assignee default avatar', () => { - beforeEach(done => { + beforeEach((done) => { global.gon.default_avatar_url = 'default_avatar'; wrapper.setProps({ @@ -189,7 +189,7 @@ describe('Issue card component', () => { }); describe('multiple assignees', () => { - beforeEach(done => { + beforeEach((done) => { wrapper.setProps({ issue: { ...wrapper.props('issue'), @@ -224,7 +224,7 @@ describe('Issue card component', () => { }); describe('more than three assignees', () => { - beforeEach(done => { + beforeEach((done) => { const { assignees } = wrapper.props('issue'); assignees.push( new ListAssignee({ @@ -245,23 +245,18 @@ describe('Issue card component', () => { }); it('renders more avatar counter', () => { - expect( - wrapper - .find('.board-card-assignee .avatar-counter') - .text() - .trim(), - ).toEqual('+2'); + expect(wrapper.find('.board-card-assignee .avatar-counter').text().trim()).toEqual('+2'); }); it('renders two assignees', () => { expect(wrapper.findAll('.board-card-assignee .avatar').length).toEqual(2); }); - it('renders 99+ avatar counter', done => { + it('renders 99+ avatar counter', (done) => { const assignees = [ ...wrapper.props('issue').assignees, ...range(5, 103).map( - i => + (i) => new ListAssignee({ id: i, name: 'name', @@ -278,12 +273,7 @@ describe('Issue card component', () => { }); wrapper.vm.$nextTick(() => { - expect( - wrapper - .find('.board-card-assignee .avatar-counter') - .text() - .trim(), - ).toEqual('99+'); + expect(wrapper.find('.board-card-assignee .avatar-counter').text().trim()).toEqual('99+'); done(); }); }); @@ -291,7 +281,7 @@ describe('Issue card component', () => { }); describe('labels', () => { - beforeEach(done => { + beforeEach((done) => { issue.addLabel(label1); wrapper.setProps({ issue: { ...issue } }); @@ -306,7 +296,7 @@ describe('Issue card component', () => { expect(label.props('backgroundColor')).toEqual(label1.color); }); - it('does not render label if label does not have an ID', done => { + it('does not render label if label does not have an ID', (done) => { issue.addLabel( new ListLabel({ title: 'closed', @@ -325,7 +315,7 @@ describe('Issue card component', () => { }); describe('blocked', () => { - beforeEach(done => { + beforeEach((done) => { wrapper.setProps({ issue: { ...wrapper.props('issue'), diff --git a/spec/frontend/boards/issue_card_inner_spec.js b/spec/frontend/boards/issue_card_inner_spec.js new file mode 100644 index 00000000000..f9ad78494af --- /dev/null +++ b/spec/frontend/boards/issue_card_inner_spec.js @@ -0,0 +1,372 @@ +import { mount } from '@vue/test-utils'; +import { range } from 'lodash'; +import { GlLabel } from '@gitlab/ui'; +import IssueCardInner from '~/boards/components/issue_card_inner.vue'; +import { mockLabelList } from './mock_data'; +import defaultStore from '~/boards/stores'; +import eventHub from '~/boards/eventhub'; +import { updateHistory } from '~/lib/utils/url_utility'; + +jest.mock('~/lib/utils/url_utility'); +jest.mock('~/boards/eventhub'); + +describe('Issue card component', () => { + const user = { + id: 1, + name: 'testing 123', + username: 'test', + avatarUrl: 'test_image', + }; + + const label1 = { + id: 3, + title: 'testing 123', + color: '#000CFF', + textColor: 'white', + description: 'test', + }; + + let wrapper; + let issue; + let list; + + const createWrapper = (props = {}, store = defaultStore) => { + wrapper = mount(IssueCardInner, { + store, + propsData: { + list, + issue, + ...props, + }, + stubs: { + GlLabel: true, + }, + provide: { + groupId: null, + rootPath: '/', + scopedLabelsAvailable: false, + }, + }); + }; + + beforeEach(() => { + list = mockLabelList; + issue = { + title: 'Testing', + id: 1, + iid: 1, + confidential: false, + labels: [list.label], + assignees: [], + referencePath: '#1', + webUrl: '/test/1', + weight: 1, + }; + + createWrapper({ issue, list }); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + jest.clearAllMocks(); + }); + + it('renders issue title', () => { + expect(wrapper.find('.board-card-title').text()).toContain(issue.title); + }); + + it('includes issue base in link', () => { + expect(wrapper.find('.board-card-title a').attributes('href')).toContain('/test'); + }); + + it('includes issue title on link', () => { + expect(wrapper.find('.board-card-title a').attributes('title')).toBe(issue.title); + }); + + it('does not render confidential icon', () => { + expect(wrapper.find('.confidential-icon').exists()).toBe(false); + }); + + it('does not render blocked icon', () => { + expect(wrapper.find('.issue-blocked-icon').exists()).toBe(false); + }); + + it('renders issue ID with #', () => { + expect(wrapper.find('.board-card-number').text()).toContain(`#${issue.id}`); + }); + + it('does not render assignee', () => { + expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(false); + }); + + describe('confidential issue', () => { + beforeEach(() => { + wrapper.setProps({ + issue: { + ...wrapper.props('issue'), + confidential: true, + }, + }); + }); + + it('renders confidential icon', () => { + expect(wrapper.find('.confidential-icon').exists()).toBe(true); + }); + }); + + describe('with assignee', () => { + describe('with avatar', () => { + beforeEach(() => { + wrapper.setProps({ + issue: { + ...wrapper.props('issue'), + assignees: [user], + updateData(newData) { + Object.assign(this, newData); + }, + }, + }); + }); + + it('renders assignee', () => { + expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(true); + }); + + it('sets title', () => { + expect(wrapper.find('.js-assignee-tooltip').text()).toContain(`${user.name}`); + }); + + it('sets users path', () => { + expect(wrapper.find('.board-card-assignee a').attributes('href')).toBe('/test'); + }); + + it('renders avatar', () => { + expect(wrapper.find('.board-card-assignee img').exists()).toBe(true); + }); + + it('renders the avatar using avatarUrl property', async () => { + wrapper.props('issue').updateData({ + ...wrapper.props('issue'), + assignees: [ + { + id: '1', + name: 'test', + state: 'active', + username: 'test_name', + avatarUrl: 'test_image_from_avatar_url', + }, + ], + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find('.board-card-assignee img').attributes('src')).toBe( + 'test_image_from_avatar_url?width=24', + ); + }); + }); + + describe('with default avatar', () => { + beforeEach(() => { + global.gon.default_avatar_url = 'default_avatar'; + + wrapper.setProps({ + issue: { + ...wrapper.props('issue'), + assignees: [ + { + id: 1, + name: 'testing 123', + username: 'test', + }, + ], + }, + }); + }); + + afterEach(() => { + global.gon.default_avatar_url = null; + }); + + it('displays defaults avatar if users avatar is null', () => { + expect(wrapper.find('.board-card-assignee img').exists()).toBe(true); + expect(wrapper.find('.board-card-assignee img').attributes('src')).toBe( + 'default_avatar?width=24', + ); + }); + }); + }); + + describe('multiple assignees', () => { + beforeEach(() => { + wrapper.setProps({ + issue: { + ...wrapper.props('issue'), + assignees: [ + { + id: 2, + name: 'user2', + username: 'user2', + avatarUrl: 'test_image', + }, + { + id: 3, + name: 'user3', + username: 'user3', + avatarUrl: 'test_image', + }, + { + id: 4, + name: 'user4', + username: 'user4', + avatarUrl: 'test_image', + }, + ], + }, + }); + }); + + it('renders all three assignees', () => { + expect(wrapper.findAll('.board-card-assignee .avatar').length).toEqual(3); + }); + + describe('more than three assignees', () => { + beforeEach(() => { + const { assignees } = wrapper.props('issue'); + assignees.push({ + id: 5, + name: 'user5', + username: 'user5', + avatarUrl: 'test_image', + }); + + wrapper.setProps({ + issue: { + ...wrapper.props('issue'), + assignees, + }, + }); + }); + + it('renders more avatar counter', () => { + expect(wrapper.find('.board-card-assignee .avatar-counter').text().trim()).toEqual('+2'); + }); + + it('renders two assignees', () => { + expect(wrapper.findAll('.board-card-assignee .avatar').length).toEqual(2); + }); + + it('renders 99+ avatar counter', async () => { + const assignees = [ + ...wrapper.props('issue').assignees, + ...range(5, 103).map((i) => ({ + id: i, + name: 'name', + username: 'username', + avatarUrl: 'test_image', + })), + ]; + wrapper.setProps({ + issue: { + ...wrapper.props('issue'), + assignees, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find('.board-card-assignee .avatar-counter').text().trim()).toEqual('99+'); + }); + }); + }); + + describe('labels', () => { + beforeEach(() => { + wrapper.setProps({ issue: { ...issue, labels: [list.label, label1] } }); + }); + + it('does not render list label but renders all other labels', () => { + expect(wrapper.findAll(GlLabel).length).toBe(1); + const label = wrapper.find(GlLabel); + expect(label.props('title')).toEqual(label1.title); + expect(label.props('description')).toEqual(label1.description); + expect(label.props('backgroundColor')).toEqual(label1.color); + }); + + it('does not render label if label does not have an ID', async () => { + wrapper.setProps({ issue: { ...issue, labels: [label1, { title: 'closed' }] } }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.findAll(GlLabel).length).toBe(1); + expect(wrapper.text()).not.toContain('closed'); + }); + }); + + describe('blocked', () => { + beforeEach(() => { + wrapper.setProps({ + issue: { + ...wrapper.props('issue'), + blocked: true, + }, + }); + }); + + it('renders blocked icon if issue is blocked', () => { + expect(wrapper.find('.issue-blocked-icon').exists()).toBe(true); + }); + }); + + describe('filterByLabel method', () => { + beforeEach(() => { + delete window.location; + + wrapper.setProps({ + updateFilters: true, + }); + }); + + describe('when selected label is not in the filter', () => { + beforeEach(() => { + jest.spyOn(wrapper.vm, 'performSearch').mockImplementation(() => {}); + window.location = { search: '' }; + wrapper.vm.filterByLabel(label1); + }); + + it('calls updateHistory', () => { + expect(updateHistory).toHaveBeenCalledTimes(1); + }); + + it('dispatches performSearch vuex action', () => { + expect(wrapper.vm.performSearch).toHaveBeenCalledTimes(1); + }); + + it('emits updateTokens event', () => { + expect(eventHub.$emit).toHaveBeenCalledTimes(1); + expect(eventHub.$emit).toHaveBeenCalledWith('updateTokens'); + }); + }); + + describe('when selected label is already in the filter', () => { + beforeEach(() => { + jest.spyOn(wrapper.vm, 'performSearch').mockImplementation(() => {}); + window.location = { search: '?label_name[]=testing%20123' }; + wrapper.vm.filterByLabel(label1); + }); + + it('does not call updateHistory', () => { + expect(updateHistory).not.toHaveBeenCalled(); + }); + + it('does not dispatch performSearch vuex action', () => { + expect(wrapper.vm.performSearch).not.toHaveBeenCalled(); + }); + + it('does not emit updateTokens event', () => { + expect(eventHub.$emit).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/boards/list_spec.js b/spec/frontend/boards/list_spec.js index b731bb6e474..db01f62c9a6 100644 --- a/spec/frontend/boards/list_spec.js +++ b/spec/frontend/boards/list_spec.js @@ -37,7 +37,7 @@ describe('List model', () => { describe('list type', () => { const notExpandableList = ['blank']; - const table = Object.keys(ListType).map(k => { + const table = Object.keys(ListType).map((k) => { const value = ListType[k]; return [value, !notExpandableList.includes(value)]; }); @@ -186,7 +186,7 @@ describe('List model', () => { list.issues = []; }); - it('adds new issue to top of list', done => { + it('adds new issue to top of list', (done) => { const user = new ListAssignee({ id: 1, name: 'testing 123', diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index ea6c52c6830..d5cfb9b7d07 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -263,7 +263,7 @@ export const BoardsMockData = { }, }; -export const boardsMockInterceptor = config => { +export const boardsMockInterceptor = (config) => { const body = BoardsMockData[config.method.toUpperCase()][config.url]; return [200, body]; }; @@ -285,7 +285,7 @@ export const setMockEndpoints = (opts = {}) => { export const mockList = { id: 'gid://gitlab/List/1', title: 'Backlog', - position: null, + position: -Infinity, listType: 'backlog', collapsed: false, label: null, @@ -318,7 +318,7 @@ export const mockLists = [mockList, mockLabelList]; export const mockListsById = keyBy(mockLists, 'id'); -export const mockListsWithModel = mockLists.map(listMock => +export const mockListsWithModel = mockLists.map((listMock) => Vue.observable(new List({ ...listMock, doNotFetchIssues: true })), ); @@ -350,3 +350,33 @@ export const issues = { [mockIssue3.id]: mockIssue3, [mockIssue4.id]: mockIssue4, }; + +export const mockRawGroupProjects = [ + { + id: 0, + name: 'Example Project', + name_with_namespace: 'Awesome Group / Example Project', + path_with_namespace: 'awesome-group/example-project', + }, + { + id: 1, + name: 'Foobar Project', + name_with_namespace: 'Awesome Group / Foobar Project', + path_with_namespace: 'awesome-group/foobar-project', + }, +]; + +export const mockGroupProjects = [ + { + id: 0, + name: 'Example Project', + nameWithNamespace: 'Awesome Group / Example Project', + fullPath: 'awesome-group/example-project', + }, + { + id: 1, + name: 'Foobar Project', + nameWithNamespace: 'Awesome Group / Foobar Project', + fullPath: 'awesome-group/foobar-project', + }, +]; diff --git a/spec/frontend/boards/project_select_deprecated_spec.js b/spec/frontend/boards/project_select_deprecated_spec.js new file mode 100644 index 00000000000..e4f8f96bd33 --- /dev/null +++ b/spec/frontend/boards/project_select_deprecated_spec.js @@ -0,0 +1,261 @@ +import { mount } from '@vue/test-utils'; +import axios from 'axios'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; +import httpStatus from '~/lib/utils/http_status'; +import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; +import { ListType } from '~/boards/constants'; +import eventHub from '~/boards/eventhub'; +import { deprecatedCreateFlash as flash } from '~/flash'; + +import ProjectSelect from '~/boards/components/project_select_deprecated.vue'; + +import { listObj, mockRawGroupProjects } from './mock_data'; + +jest.mock('~/boards/eventhub'); +jest.mock('~/flash'); + +const dummyGon = { + api_version: 'v4', + relative_url_root: '/gitlab', +}; + +const mockGroupId = 1; +const mockProjectsList1 = mockRawGroupProjects.slice(0, 1); +const mockProjectsList2 = mockRawGroupProjects.slice(1); +const mockDefaultFetchOptions = { + with_issues_enabled: true, + with_shared: false, + include_subgroups: true, + order_by: 'similarity', +}; + +const itemsPerPage = 20; + +describe('ProjectSelect component', () => { + let wrapper; + let axiosMock; + + const findLabel = () => wrapper.find("[data-testid='header-label']"); + const findGlDropdown = () => wrapper.find(GlDropdown); + const findGlDropdownLoadingIcon = () => + findGlDropdown().find('button:first-child').find(GlLoadingIcon); + const findGlSearchBoxByType = () => wrapper.find(GlSearchBoxByType); + const findGlDropdownItems = () => wrapper.findAll(GlDropdownItem); + const findFirstGlDropdownItem = () => findGlDropdownItems().at(0); + const findInMenuLoadingIcon = () => wrapper.find("[data-testid='dropdown-text-loading-icon']"); + const findEmptySearchMessage = () => wrapper.find("[data-testid='empty-result-message']"); + + const mockGetRequest = (data = [], statusCode = httpStatus.OK) => { + axiosMock + .onGet(`/gitlab/api/v4/groups/${mockGroupId}/projects.json`) + .replyOnce(statusCode, data); + }; + + const searchForProject = async (keyword, waitForAll = true) => { + findGlSearchBoxByType().vm.$emit('input', keyword); + + if (waitForAll) { + await axios.waitForAll(); + } + }; + + const createWrapper = async ({ list = listObj } = {}, waitForAll = true) => { + wrapper = mount(ProjectSelect, { + propsData: { + list, + }, + provide: { + groupId: 1, + }, + }); + + if (waitForAll) { + await axios.waitForAll(); + } + }; + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + window.gon = dummyGon; + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + axiosMock.restore(); + jest.clearAllMocks(); + }); + + it('displays a header title', async () => { + createWrapper({}); + + expect(findLabel().text()).toBe('Projects'); + }); + + it('renders a default dropdown text', async () => { + createWrapper({}); + + expect(findGlDropdown().exists()).toBe(true); + expect(findGlDropdown().text()).toContain('Select a project'); + }); + + describe('when mounted', () => { + it('displays a loading icon while projects are being fetched', async () => { + mockGetRequest([]); + + createWrapper({}, false); + + expect(findGlDropdownLoadingIcon().exists()).toBe(true); + + await axios.waitForAll(); + + expect(axiosMock.history.get[0].params).toMatchObject({ search: '' }); + expect(axiosMock.history.get[0].url).toBe( + `/gitlab/api/v4/groups/${mockGroupId}/projects.json`, + ); + + expect(findGlDropdownLoadingIcon().exists()).toBe(false); + }); + }); + + describe('when dropdown menu is open', () => { + describe('by default', () => { + beforeEach(async () => { + mockGetRequest(mockProjectsList1); + + await createWrapper(); + }); + + it('shows GlSearchBoxByType with default attributes', () => { + expect(findGlSearchBoxByType().exists()).toBe(true); + expect(findGlSearchBoxByType().vm.$attrs).toMatchObject({ + placeholder: 'Search projects', + debounce: '250', + }); + }); + + it("displays the fetched project's name", () => { + expect(findFirstGlDropdownItem().exists()).toBe(true); + expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList1[0].name); + }); + + it("doesn't render loading icon in the menu", () => { + expect(findInMenuLoadingIcon().isVisible()).toBe(false); + }); + + it('renders empty search result message', async () => { + await createWrapper(); + + expect(findEmptySearchMessage().exists()).toBe(true); + }); + }); + + describe('when a project is selected', () => { + beforeEach(async () => { + mockGetRequest(mockProjectsList1); + + await createWrapper(); + + await findFirstGlDropdownItem().find('button').trigger('click'); + }); + + it('emits setSelectedProject with correct project metadata', () => { + expect(eventHub.$emit).toHaveBeenCalledWith('setSelectedProject', { + id: mockProjectsList1[0].id, + path: mockProjectsList1[0].path_with_namespace, + name: mockProjectsList1[0].name, + namespacedName: mockProjectsList1[0].name_with_namespace, + }); + }); + + it('renders the name of the selected project', () => { + expect(findGlDropdown().find('.gl-new-dropdown-button-text').text()).toBe( + mockProjectsList1[0].name, + ); + }); + }); + + describe('when user searches for a project', () => { + beforeEach(async () => { + mockGetRequest(mockProjectsList1); + + await createWrapper(); + }); + + it('calls API with correct parameters with default fetch options', async () => { + await searchForProject('foobar'); + + const expectedApiParams = { + search: 'foobar', + per_page: itemsPerPage, + ...mockDefaultFetchOptions, + }; + + expect(axiosMock.history.get[1].params).toMatchObject(expectedApiParams); + expect(axiosMock.history.get[1].url).toBe( + `/gitlab/api/v4/groups/${mockGroupId}/projects.json`, + ); + }); + + describe("when list type is defined and isn't backlog", () => { + it('calls API with an additional fetch option (min_access_level)', async () => { + axiosMock.reset(); + + await createWrapper({ list: { ...listObj, type: ListType.label } }); + + await searchForProject('foobar'); + + const expectedApiParams = { + search: 'foobar', + per_page: itemsPerPage, + ...mockDefaultFetchOptions, + min_access_level: featureAccessLevel.EVERYONE, + }; + + expect(axiosMock.history.get[1].params).toMatchObject(expectedApiParams); + expect(axiosMock.history.get[1].url).toBe( + `/gitlab/api/v4/groups/${mockGroupId}/projects.json`, + ); + }); + }); + + it('displays and hides gl-loading-icon while and after fetching data', async () => { + await searchForProject('some keyword', false); + + await wrapper.vm.$nextTick(); + + expect(findInMenuLoadingIcon().isVisible()).toBe(true); + + await axios.waitForAll(); + + expect(findInMenuLoadingIcon().isVisible()).toBe(false); + }); + + it('flashes an error message when fetching fails', async () => { + mockGetRequest([], httpStatus.INTERNAL_SERVER_ERROR); + + await searchForProject('foobar'); + + expect(flash).toHaveBeenCalledTimes(1); + expect(flash).toHaveBeenCalledWith('Something went wrong while fetching projects'); + }); + + describe('with non-empty search result', () => { + beforeEach(async () => { + mockGetRequest(mockProjectsList2); + + await searchForProject('foobar'); + }); + + it('displays the retrieved list of projects', async () => { + expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList2[0].name); + }); + + it('does not render empty search result message', async () => { + expect(findEmptySearchMessage().exists()).toBe(false); + }); + }); + }); + }); +}); diff --git a/spec/frontend/boards/project_select_spec.js b/spec/frontend/boards/project_select_spec.js new file mode 100644 index 00000000000..14ddab3542b --- /dev/null +++ b/spec/frontend/boards/project_select_spec.js @@ -0,0 +1,154 @@ +import Vuex from 'vuex'; +import { createLocalVue, mount } from '@vue/test-utils'; +import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; +import defaultState from '~/boards/stores/state'; + +import ProjectSelect from '~/boards/components/project_select.vue'; + +import { mockList, mockGroupProjects } from './mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +const actions = { + fetchGroupProjects: jest.fn(), + setSelectedProject: jest.fn(), +}; + +const createStore = (state = defaultState) => { + return new Vuex.Store({ + state, + actions, + }); +}; + +const mockProjectsList1 = mockGroupProjects.slice(0, 1); + +describe('ProjectSelect component', () => { + let wrapper; + + const findLabel = () => wrapper.find("[data-testid='header-label']"); + const findGlDropdown = () => wrapper.find(GlDropdown); + const findGlDropdownLoadingIcon = () => + findGlDropdown().find('button:first-child').find(GlLoadingIcon); + const findGlSearchBoxByType = () => wrapper.find(GlSearchBoxByType); + const findGlDropdownItems = () => wrapper.findAll(GlDropdownItem); + const findFirstGlDropdownItem = () => findGlDropdownItems().at(0); + const findInMenuLoadingIcon = () => wrapper.find("[data-testid='dropdown-text-loading-icon']"); + const findEmptySearchMessage = () => wrapper.find("[data-testid='empty-result-message']"); + + const createWrapper = (state = {}) => { + const store = createStore({ + groupProjects: [], + groupProjectsFlags: { + isLoading: false, + pageInfo: { + hasNextPage: false, + }, + }, + ...state, + }); + + wrapper = mount(ProjectSelect, { + localVue, + propsData: { + list: mockList, + }, + store, + provide: { + groupId: 1, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('displays a header title', () => { + createWrapper(); + + expect(findLabel().text()).toBe('Projects'); + }); + + it('renders a default dropdown text', () => { + createWrapper(); + + expect(findGlDropdown().exists()).toBe(true); + expect(findGlDropdown().text()).toContain('Select a project'); + }); + + describe('when mounted', () => { + it('displays a loading icon while projects are being fetched', async () => { + createWrapper(); + + expect(findGlDropdownLoadingIcon().exists()).toBe(true); + + await wrapper.vm.$nextTick(); + + expect(findGlDropdownLoadingIcon().exists()).toBe(false); + }); + }); + + describe('when dropdown menu is open', () => { + describe('by default', () => { + beforeEach(() => { + createWrapper({ groupProjects: mockGroupProjects }); + }); + + it('shows GlSearchBoxByType with default attributes', () => { + expect(findGlSearchBoxByType().exists()).toBe(true); + expect(findGlSearchBoxByType().vm.$attrs).toMatchObject({ + placeholder: 'Search projects', + debounce: '250', + }); + }); + + it("displays the fetched project's name", () => { + expect(findFirstGlDropdownItem().exists()).toBe(true); + expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList1[0].name); + }); + + it("doesn't render loading icon in the menu", () => { + expect(findInMenuLoadingIcon().isVisible()).toBe(false); + }); + + it('does not render empty search result message', () => { + expect(findEmptySearchMessage().exists()).toBe(false); + }); + }); + + describe('when no projects are being returned', () => { + it('renders empty search result message', () => { + createWrapper(); + + expect(findEmptySearchMessage().exists()).toBe(true); + }); + }); + + describe('when a project is selected', () => { + beforeEach(() => { + createWrapper({ groupProjects: mockProjectsList1 }); + + findFirstGlDropdownItem().find('button').trigger('click'); + }); + + it('renders the name of the selected project', () => { + expect(findGlDropdown().find('.gl-new-dropdown-button-text').text()).toBe( + mockProjectsList1[0].name, + ); + }); + }); + + describe('when projects are loading', () => { + beforeEach(() => { + createWrapper({ groupProjectsFlags: { isLoading: true } }); + }); + + it('displays and hides gl-loading-icon while and after fetching data', () => { + expect(findInMenuLoadingIcon().isVisible()).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 0cae6456887..e4209cd5e55 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -9,19 +9,26 @@ import { mockMilestone, labels, mockActiveIssue, + mockGroupProjects, } from '../mock_data'; import actions, { gqlClient } from '~/boards/stores/actions'; import * as types from '~/boards/stores/mutation_types'; import { inactiveId } from '~/boards/constants'; import issueMoveListMutation from '~/boards/graphql/issue_move_list.mutation.graphql'; import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql'; +import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql'; import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql'; -import { fullBoardId, formatListIssues, formatBoardLists } from '~/boards/boards_util'; +import { + fullBoardId, + formatListIssues, + formatBoardLists, + formatIssueInput, +} from '~/boards/boards_util'; import createFlash from '~/flash'; jest.mock('~/flash'); -const expectNotImplemented = action => { +const expectNotImplemented = (action) => { it('is not implemented', () => { expect(action).toThrow(new Error('Not implemented!')); }); @@ -29,7 +36,7 @@ const expectNotImplemented = action => { // We need this helper to make sure projectPath is including // subgroups when the movIssue action is called. -const getProjectPath = path => path.split('#')[0]; +const getProjectPath = (path) => path.split('#')[0]; beforeEach(() => { window.gon = { features: {} }; @@ -53,7 +60,7 @@ describe('setInitialBoardData', () => { }); describe('setFilters', () => { - it('should commit mutation SET_FILTERS', done => { + it('should commit mutation SET_FILTERS', (done) => { const state = { filters: {}, }; @@ -72,11 +79,11 @@ describe('setFilters', () => { }); describe('performSearch', () => { - it('should dispatch setFilters action', done => { + it('should dispatch setFilters action', (done) => { testAction(actions.performSearch, {}, {}, [], [{ type: 'setFilters', payload: {} }], done); }); - it('should dispatch setFilters, fetchLists and resetIssues action when graphqlBoardLists FF is on', done => { + it('should dispatch setFilters, fetchLists and resetIssues action when graphqlBoardLists FF is on', (done) => { window.gon = { features: { graphqlBoardLists: true } }; testAction( actions.performSearch, @@ -90,7 +97,7 @@ describe('performSearch', () => { }); describe('setActiveId', () => { - it('should commit mutation SET_ACTIVE_ID', done => { + it('should commit mutation SET_ACTIVE_ID', (done) => { const state = { activeId: inactiveId, }; @@ -108,10 +115,8 @@ describe('setActiveId', () => { describe('fetchLists', () => { const state = { - endpoints: { - fullPath: 'gitlab-org', - boardId: 1, - }, + fullPath: 'gitlab-org', + boardId: '1', filterParams: {}, boardType: 'group', }; @@ -131,7 +136,7 @@ describe('fetchLists', () => { const formattedLists = formatBoardLists(queryResponse.data.group.board.lists); - it('should commit mutations RECEIVE_BOARD_LISTS_SUCCESS on success', done => { + it('should commit mutations RECEIVE_BOARD_LISTS_SUCCESS on success', (done) => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); testAction( @@ -149,7 +154,7 @@ describe('fetchLists', () => { ); }); - it('dispatch createList action when backlog list does not exist and is not hidden', done => { + it('dispatch createList action when backlog list does not exist and is not hidden', (done) => { queryResponse = { data: { group: { @@ -181,7 +186,7 @@ describe('fetchLists', () => { }); describe('createList', () => { - it('should dispatch addList action when creating backlog list', done => { + it('should dispatch addList action when creating backlog list', (done) => { const backlogList = { id: 'gid://gitlab/List/1', listType: 'backlog', @@ -201,7 +206,8 @@ describe('createList', () => { ); const state = { - endpoints: { fullPath: 'gitlab-org', boardId: '1' }, + fullPath: 'gitlab-org', + boardId: '1', boardType: 'group', disabled: false, boardLists: [{ type: 'closed' }], @@ -217,7 +223,7 @@ describe('createList', () => { ); }); - it('should commit CREATE_LIST_FAILURE mutation when API returns an error', done => { + it('should commit CREATE_LIST_FAILURE mutation when API returns an error', (done) => { jest.spyOn(gqlClient, 'mutate').mockReturnValue( Promise.resolve({ data: { @@ -230,7 +236,8 @@ describe('createList', () => { ); const state = { - endpoints: { fullPath: 'gitlab-org', boardId: '1' }, + fullPath: 'gitlab-org', + boardId: '1', boardType: 'group', disabled: false, boardLists: [{ type: 'closed' }], @@ -248,14 +255,15 @@ describe('createList', () => { }); describe('moveList', () => { - it('should commit MOVE_LIST mutation and dispatch updateList action', done => { + it('should commit MOVE_LIST mutation and dispatch updateList action', (done) => { const initialBoardListsState = { 'gid://gitlab/List/1': mockLists[0], 'gid://gitlab/List/2': mockLists[1], }; const state = { - endpoints: { fullPath: 'gitlab-org', boardId: '1' }, + fullPath: 'gitlab-org', + boardId: '1', boardType: 'group', disabled: false, boardLists: initialBoardListsState, @@ -297,7 +305,8 @@ describe('moveList', () => { }; const state = { - endpoints: { fullPath: 'gitlab-org', boardId: '1' }, + fullPath: 'gitlab-org', + boardId: '1', boardType: 'group', disabled: false, boardLists: initialBoardListsState, @@ -319,7 +328,7 @@ describe('moveList', () => { }); describe('updateList', () => { - it('should commit UPDATE_LIST_FAILURE mutation when API returns an error', done => { + it('should commit UPDATE_LIST_FAILURE mutation when API returns an error', (done) => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { updateBoardList: { @@ -330,7 +339,8 @@ describe('updateList', () => { }); const state = { - endpoints: { fullPath: 'gitlab-org', boardId: '1' }, + fullPath: 'gitlab-org', + boardId: '1', boardType: 'group', disabled: false, boardLists: [{ type: 'closed' }], @@ -429,15 +439,13 @@ describe('fetchIssuesForList', () => { const listId = mockLists[0].id; const state = { - endpoints: { - fullPath: 'gitlab-org', - boardId: 1, - }, + fullPath: 'gitlab-org', + boardId: '1', filterParams: {}, boardType: 'group', }; - const mockIssuesNodes = mockIssues.map(issue => ({ node: issue })); + const mockIssuesNodes = mockIssues.map((issue) => ({ node: issue })); const pageInfo = { endCursor: '', @@ -470,7 +478,7 @@ describe('fetchIssuesForList', () => { [listId]: pageInfo, }; - it('should commit mutations REQUEST_ISSUES_FOR_LIST and RECEIVE_ISSUES_FOR_LIST_SUCCESS on success', done => { + it('should commit mutations REQUEST_ISSUES_FOR_LIST and RECEIVE_ISSUES_FOR_LIST_SUCCESS on success', (done) => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); testAction( @@ -492,7 +500,7 @@ describe('fetchIssuesForList', () => { ); }); - it('should commit mutations REQUEST_ISSUES_FOR_LIST and RECEIVE_ISSUES_FOR_LIST_FAILURE on failure', done => { + it('should commit mutations REQUEST_ISSUES_FOR_LIST and RECEIVE_ISSUES_FOR_LIST_FAILURE on failure', (done) => { jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject()); testAction( @@ -525,12 +533,13 @@ describe('moveIssue', () => { }; const issues = { - '436': mockIssue, - '437': mockIssue2, + 436: mockIssue, + 437: mockIssue2, }; const state = { - endpoints: { fullPath: 'gitlab-org', boardId: '1' }, + fullPath: 'gitlab-org', + boardId: '1', boardType: 'group', disabled: false, boardLists: mockLists, @@ -538,7 +547,7 @@ describe('moveIssue', () => { issues, }; - it('should commit MOVE_ISSUE mutation and MOVE_ISSUE_SUCCESS mutation when successful', done => { + it('should commit MOVE_ISSUE mutation and MOVE_ISSUE_SUCCESS mutation when successful', (done) => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { issueMoveList: { @@ -582,7 +591,7 @@ describe('moveIssue', () => { mutation: issueMoveListMutation, variables: { projectPath: getProjectPath(mockIssue.referencePath), - boardId: fullBoardId(state.endpoints.boardId), + boardId: fullBoardId(state.boardId), iid: mockIssue.iid, fromListId: 1, toListId: 2, @@ -613,7 +622,7 @@ describe('moveIssue', () => { expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables); }); - it('should commit MOVE_ISSUE mutation and MOVE_ISSUE_FAILURE mutation when unsuccessful', done => { + it('should commit MOVE_ISSUE mutation and MOVE_ISSUE_FAILURE mutation when unsuccessful', (done) => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { issueMoveList: { @@ -684,7 +693,7 @@ describe('setAssignees', () => { }); }); - it('calls the correct mutation with the correct values', done => { + it('calls the correct mutation with the correct values', (done) => { testAction( actions.setAssignees, {}, @@ -724,8 +733,27 @@ describe('setAssignees', () => { describe('createNewIssue', () => { const state = { boardType: 'group', - endpoints: { - fullPath: 'gitlab-org/gitlab', + fullPath: 'gitlab-org/gitlab', + boardConfig: { + labelIds: [], + assigneeId: null, + milestoneId: -1, + }, + }; + + const stateWithBoardConfig = { + boardConfig: { + labels: [ + { + id: 5, + title: 'Test', + color: '#ff0000', + description: 'testing;', + textColor: 'white', + }, + ], + assigneeId: 2, + milestoneId: 3, }, }; @@ -743,11 +771,59 @@ describe('createNewIssue', () => { expect(result).toEqual(mockIssue); }); - it('should commit CREATE_ISSUE_FAILURE mutation when API returns an error', done => { + it('should add board scope to the issue being created', async () => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { createIssue: { - issue: {}, + issue: mockIssue, + errors: [], + }, + }, + }); + + await actions.createNewIssue({ state: stateWithBoardConfig }, mockIssue); + expect(gqlClient.mutate).toHaveBeenCalledWith({ + mutation: issueCreateMutation, + variables: { + input: formatIssueInput(mockIssue, stateWithBoardConfig.boardConfig), + }, + }); + }); + + it('should add board scope by merging attributes to the issue being created', async () => { + const issue = { + ...mockIssue, + assigneeIds: ['gid://gitlab/User/1'], + labelIds: ['gid://gitlab/GroupLabel/4'], + }; + + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + createIssue: { + issue, + errors: [], + }, + }, + }); + + const payload = formatIssueInput(issue, stateWithBoardConfig.boardConfig); + + await actions.createNewIssue({ state: stateWithBoardConfig }, issue); + expect(gqlClient.mutate).toHaveBeenCalledWith({ + mutation: issueCreateMutation, + variables: { + input: formatIssueInput(issue, stateWithBoardConfig.boardConfig), + }, + }); + expect(payload.labelIds).toEqual(['gid://gitlab/GroupLabel/4', 'gid://gitlab/GroupLabel/5']); + expect(payload.assigneeIds).toEqual(['gid://gitlab/User/1', 'gid://gitlab/User/2']); + }); + + it('should commit CREATE_ISSUE_FAILURE mutation when API returns an error', (done) => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + createIssue: { + issue: mockIssue, errors: [{ foo: 'bar' }], }, }, @@ -767,7 +843,7 @@ describe('createNewIssue', () => { }); describe('addListIssue', () => { - it('should commit ADD_ISSUE_TO_LIST mutation', done => { + it('should commit ADD_ISSUE_TO_LIST mutation', (done) => { const payload = { list: mockLists[0], issue: mockIssue, @@ -788,14 +864,14 @@ describe('addListIssue', () => { describe('setActiveIssueLabels', () => { const state = { issues: { [mockIssue.id]: mockIssue } }; const getters = { activeIssue: mockIssue }; - const testLabelIds = labels.map(label => label.id); + const testLabelIds = labels.map((label) => label.id); const input = { addLabelIds: testLabelIds, removeLabelIds: [], projectPath: 'h/b', }; - it('should assign labels on success', done => { + it('should assign labels on success', (done) => { jest .spyOn(gqlClient, 'mutate') .mockResolvedValue({ data: { updateIssue: { issue: { labels: { nodes: labels } } } } }); @@ -839,7 +915,7 @@ describe('setActiveIssueDueDate', () => { projectPath: 'h/b', }; - it('should commit due date after setting the issue', done => { + it('should commit due date after setting the issue', (done) => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { updateIssue: { @@ -890,7 +966,7 @@ describe('setActiveIssueSubscribed', () => { projectPath: 'gitlab-org/gitlab-test', }; - it('should commit subscribed status', done => { + it('should commit subscribed status', (done) => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { issueSetSubscription: { @@ -944,7 +1020,7 @@ describe('setActiveIssueMilestone', () => { projectPath: 'h/b', }; - it('should commit milestone after setting the issue', done => { + it('should commit milestone after setting the issue', (done) => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { updateIssue: { @@ -986,6 +1062,145 @@ describe('setActiveIssueMilestone', () => { }); }); +describe('setActiveIssueTitle', () => { + const state = { issues: { [mockIssue.id]: mockIssue } }; + const getters = { activeIssue: mockIssue }; + const testTitle = 'Test Title'; + const input = { + title: testTitle, + projectPath: 'h/b', + }; + + it('should commit title after setting the issue', (done) => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + updateIssue: { + issue: { + title: testTitle, + }, + errors: [], + }, + }, + }); + + const payload = { + issueId: getters.activeIssue.id, + prop: 'title', + value: testTitle, + }; + + testAction( + actions.setActiveIssueTitle, + input, + { ...state, ...getters }, + [ + { + type: types.UPDATE_ISSUE_BY_ID, + payload, + }, + ], + [], + done, + ); + }); + + it('throws error if fails', async () => { + jest + .spyOn(gqlClient, 'mutate') + .mockResolvedValue({ data: { updateIssue: { errors: ['failed mutation'] } } }); + + await expect(actions.setActiveIssueTitle({ getters }, input)).rejects.toThrow(Error); + }); +}); + +describe('fetchGroupProjects', () => { + const state = { + fullPath: 'gitlab-org', + }; + + const pageInfo = { + endCursor: '', + hasNextPage: false, + }; + + const queryResponse = { + data: { + group: { + projects: { + nodes: mockGroupProjects, + pageInfo: { + endCursor: '', + hasNextPage: false, + }, + }, + }, + }, + }; + + it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_SUCCESS on success', (done) => { + jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); + + testAction( + actions.fetchGroupProjects, + {}, + state, + [ + { + type: types.REQUEST_GROUP_PROJECTS, + payload: false, + }, + { + type: types.RECEIVE_GROUP_PROJECTS_SUCCESS, + payload: { projects: mockGroupProjects, pageInfo, fetchNext: false }, + }, + ], + [], + done, + ); + }); + + it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_FAILURE on failure', (done) => { + jest.spyOn(gqlClient, 'query').mockRejectedValue(); + + testAction( + actions.fetchGroupProjects, + {}, + state, + [ + { + type: types.REQUEST_GROUP_PROJECTS, + payload: false, + }, + { + type: types.RECEIVE_GROUP_PROJECTS_FAILURE, + }, + ], + [], + done, + ); + }); +}); + +describe('setSelectedProject', () => { + it('should commit mutation SET_SELECTED_PROJECT', (done) => { + const project = mockGroupProjects[0]; + + testAction( + actions.setSelectedProject, + project, + {}, + [ + { + type: types.SET_SELECTED_PROJECT, + payload: project, + }, + ], + [], + done, + ); + }); +}); + describe('fetchBacklog', () => { expectNotImplemented(actions.fetchBacklog); }); diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js index 6ceb8867d1f..44b41b5667d 100644 --- a/spec/frontend/boards/stores/getters_spec.js +++ b/spec/frontend/boards/stores/getters_spec.js @@ -39,7 +39,7 @@ describe('Boards - Getters', () => { }); describe('getIssueById', () => { - const state = { issues: { '1': 'issue' } }; + const state = { issues: { 1: 'issue' } }; it.each` id | expected @@ -56,7 +56,7 @@ describe('Boards - Getters', () => { ${'1'} | ${'issue'} ${''} | ${{}} `('returns $expected when $id is passed to state', ({ id, expected }) => { - const state = { issues: { '1': 'issue' }, activeId: id }; + const state = { issues: { 1: 'issue' }, activeId: id }; expect(getters.activeIssue(state)).toEqual(expected); }); @@ -84,7 +84,7 @@ describe('Boards - Getters', () => { issues, }; it('returns issues for a given listId', () => { - const getIssueById = issueId => [mockIssue, mockIssue2].find(({ id }) => id === issueId); + const getIssueById = (issueId) => [mockIssue, mockIssue2].find(({ id }) => id === issueId); expect(getters.getIssuesByList(boardsState, { getIssueById })('gid://gitlab/List/2')).toEqual( mockIssues, diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index d93119ede3d..c5fe0e22c3c 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -1,9 +1,9 @@ import mutations from '~/boards/stores/mutations'; import * as types from '~/boards/stores/mutation_types'; import defaultState from '~/boards/stores/state'; -import { mockLists, rawIssue, mockIssue, mockIssue2 } from '../mock_data'; +import { mockLists, rawIssue, mockIssue, mockIssue2, mockGroupProjects } from '../mock_data'; -const expectNotImplemented = action => { +const expectNotImplemented = (action) => { it('is not implemented', () => { expect(action).toThrow(new Error('Not implemented!')); }); @@ -23,14 +23,8 @@ describe('Board Store Mutations', () => { describe('SET_INITIAL_BOARD_DATA', () => { it('Should set initial Boards data to state', () => { - const endpoints = { - boardsEndpoint: '/boards/', - recentBoardsEndpoint: '/boards/', - listsEndpoint: '/boards/lists', - bulkUpdatePath: '/boards/bulkUpdate', - boardId: 1, - fullPath: 'gitlab-org', - }; + const boardId = 1; + const fullPath = 'gitlab-org'; const boardType = 'group'; const disabled = false; const boardConfig = { @@ -38,13 +32,15 @@ describe('Board Store Mutations', () => { }; mutations[types.SET_INITIAL_BOARD_DATA](state, { - ...endpoints, + boardId, + fullPath, boardType, disabled, boardConfig, }); - expect(state.endpoints).toEqual(endpoints); + expect(state.boardId).toEqual(boardId); + expect(state.fullPath).toEqual(fullPath); expect(state.boardType).toEqual(boardType); expect(state.disabled).toEqual(disabled); expect(state.boardConfig).toEqual(boardConfig); @@ -240,7 +236,7 @@ describe('Board Store Mutations', () => { 'gid://gitlab/List/1': [mockIssue.id], }; const issues = { - '1': mockIssue, + 1: mockIssue, }; state = { @@ -349,8 +345,8 @@ describe('Board Store Mutations', () => { }; const issues = { - '1': mockIssue, - '2': mockIssue2, + 1: mockIssue, + 2: mockIssue2, }; state = { @@ -378,7 +374,7 @@ describe('Board Store Mutations', () => { describe('MOVE_ISSUE_SUCCESS', () => { it('updates issue in issues state', () => { const issues = { - '436': { id: rawIssue.id }, + 436: { id: rawIssue.id }, }; state = { @@ -390,7 +386,7 @@ describe('Board Store Mutations', () => { issue: rawIssue, }); - expect(state.issues).toEqual({ '436': { ...mockIssue, id: 436 } }); + expect(state.issues).toEqual({ 436: { ...mockIssue, id: 436 } }); }); }); @@ -450,7 +446,7 @@ describe('Board Store Mutations', () => { 'gid://gitlab/List/1': [mockIssue.id], }; const issues = { - '1': mockIssue, + 1: mockIssue, }; state = { @@ -476,8 +472,8 @@ describe('Board Store Mutations', () => { 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id], }; const issues = { - '1': mockIssue, - '2': mockIssue2, + 1: mockIssue, + 2: mockIssue2, }; state = { @@ -500,8 +496,8 @@ describe('Board Store Mutations', () => { 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id], }; const issues = { - '1': mockIssue, - '2': mockIssue2, + 1: mockIssue, + 2: mockIssue2, }; state = { @@ -533,4 +529,64 @@ describe('Board Store Mutations', () => { describe('TOGGLE_EMPTY_STATE', () => { expectNotImplemented(mutations.TOGGLE_EMPTY_STATE); }); + + describe('REQUEST_GROUP_PROJECTS', () => { + it('Should set isLoading in groupProjectsFlags to true in state when fetchNext is false', () => { + mutations[types.REQUEST_GROUP_PROJECTS](state, false); + + expect(state.groupProjectsFlags.isLoading).toBe(true); + }); + + it('Should set isLoading in groupProjectsFlags to true in state when fetchNext is true', () => { + mutations[types.REQUEST_GROUP_PROJECTS](state, true); + + expect(state.groupProjectsFlags.isLoadingMore).toBe(true); + }); + }); + + describe('RECEIVE_GROUP_PROJECTS_SUCCESS', () => { + it('Should set groupProjects and pageInfo to state and isLoading in groupProjectsFlags to false', () => { + mutations[types.RECEIVE_GROUP_PROJECTS_SUCCESS](state, { + projects: mockGroupProjects, + pageInfo: { hasNextPage: false }, + }); + + expect(state.groupProjects).toEqual(mockGroupProjects); + expect(state.groupProjectsFlags.isLoading).toBe(false); + expect(state.groupProjectsFlags.pageInfo).toEqual({ hasNextPage: false }); + }); + + it('Should merge projects in groupProjects in state when fetchNext is true', () => { + state = { + ...state, + groupProjects: [mockGroupProjects[0]], + }; + + mutations[types.RECEIVE_GROUP_PROJECTS_SUCCESS](state, { + projects: [mockGroupProjects[1]], + fetchNext: true, + }); + + expect(state.groupProjects).toEqual(mockGroupProjects); + }); + }); + + describe('RECEIVE_GROUP_PROJECTS_FAILURE', () => { + it('Should set error in state and isLoading in groupProjectsFlags to false', () => { + mutations[types.RECEIVE_GROUP_PROJECTS_FAILURE](state); + + expect(state.error).toEqual( + 'An error occurred while fetching group projects. Please try again.', + ); + expect(state.groupProjectsFlags.isLoading).toBe(false); + }); + }); + + describe('SET_SELECTED_PROJECT', () => { + it('Should set selectedProject to state', () => { + mutations[types.SET_SELECTED_PROJECT](state, mockGroupProjects[0]); + + expect(state.selectedProject).toEqual(mockGroupProjects[0]); + }); + }); }); |