diff options
Diffstat (limited to 'spec/frontend/boards/components')
21 files changed, 764 insertions, 350 deletions
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: {}, |