diff options
Diffstat (limited to 'spec/frontend/issuable/bulk_update_sidebar/components/move_issues_button_spec.js')
-rw-r--r-- | spec/frontend/issuable/bulk_update_sidebar/components/move_issues_button_spec.js | 554 |
1 files changed, 554 insertions, 0 deletions
diff --git a/spec/frontend/issuable/bulk_update_sidebar/components/move_issues_button_spec.js b/spec/frontend/issuable/bulk_update_sidebar/components/move_issues_button_spec.js new file mode 100644 index 00000000000..c432d722637 --- /dev/null +++ b/spec/frontend/issuable/bulk_update_sidebar/components/move_issues_button_spec.js @@ -0,0 +1,554 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import { cloneDeep } from 'lodash'; +import VueApollo from 'vue-apollo'; +import { GlAlert } from '@gitlab/ui'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; +import createFlash from '~/flash'; +import { logError } from '~/lib/logger'; +import IssuableMoveDropdown from '~/vue_shared/components/sidebar/issuable_move_dropdown.vue'; +import MoveIssuesButton from '~/issuable/bulk_update_sidebar/components/move_issues_button.vue'; +import issuableEventHub from '~/issues/list/eventhub'; +import moveIssueMutation from '~/issuable/bulk_update_sidebar/components/graphql/mutations/move_issue.mutation.graphql'; +import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; +import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql'; +import { getIssuesCountsQueryResponse, getIssuesQueryResponse } from 'jest/issues/list/mock_data'; +import { + WORK_ITEM_TYPE_ENUM_ISSUE, + WORK_ITEM_TYPE_ENUM_INCIDENT, + WORK_ITEM_TYPE_ENUM_TASK, + WORK_ITEM_TYPE_ENUM_TEST_CASE, +} from '~/work_items/constants'; + +jest.mock('~/flash'); +jest.mock('~/lib/logger'); +useMockLocationHelper(); + +const mockDefaultProps = { + projectFullPath: 'flight/FlightJS', + projectsFetchPath: '/-/autocomplete/projects?project_id=1', +}; + +const mockDestinationProject = { + full_path: 'gitlab-org/GitLabTest', +}; + +const mockMutationErrorMessage = 'Example error message'; + +const mockIssue = { + iid: '15', + type: WORK_ITEM_TYPE_ENUM_ISSUE, +}; + +const mockIncident = { + iid: '32', + type: WORK_ITEM_TYPE_ENUM_INCIDENT, +}; + +const mockTask = { + iid: '40', + type: WORK_ITEM_TYPE_ENUM_TASK, +}; + +const mockTestCase = { + iid: '51', + type: WORK_ITEM_TYPE_ENUM_TEST_CASE, +}; + +const selectedIssuesMocks = { + tasksOnly: [mockTask], + testCasesOnly: [mockTestCase], + issuesOnly: [mockIssue, mockIncident], + tasksAndTestCases: [mockTask, mockTestCase], + issuesAndTasks: [mockIssue, mockIncident, mockTask], + issuesAndTestCases: [mockIssue, mockIncident, mockTestCase], + issuesTasksAndTestCases: [mockIssue, mockIncident, mockTask, mockTestCase], +}; + +let getIssuesQueryCompleteResponse = getIssuesQueryResponse; +if (IS_EE) { + getIssuesQueryCompleteResponse = cloneDeep(getIssuesQueryResponse); + getIssuesQueryCompleteResponse.data.project.issues.nodes[0].blockingCount = 1; + getIssuesQueryCompleteResponse.data.project.issues.nodes[0].healthStatus = null; + getIssuesQueryCompleteResponse.data.project.issues.nodes[0].weight = 5; +} + +const resolvedMutationWithoutErrorsMock = jest.fn().mockResolvedValue({ + data: { + issueMove: { + errors: [], + }, + }, +}); + +const resolvedMutationWithErrorsMock = jest.fn().mockResolvedValue({ + data: { + issueMove: { + errors: [{ message: mockMutationErrorMessage }], + }, + }, +}); + +const rejectedMutationMock = jest.fn().mockRejectedValue({}); + +const mockIssuesQueryResponse = jest.fn().mockResolvedValue(getIssuesQueryCompleteResponse); +const mockIssuesCountsQueryResponse = jest.fn().mockResolvedValue(getIssuesCountsQueryResponse); + +describe('MoveIssuesButton', () => { + Vue.use(VueApollo); + + let wrapper; + let fakeApollo; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findDropdown = () => wrapper.findComponent(IssuableMoveDropdown); + const emitMoveIssuablesEvent = () => { + findDropdown().vm.$emit('move-issuable', mockDestinationProject); + }; + + const createComponent = (data = {}, mutationResolverMock = rejectedMutationMock) => { + fakeApollo = createMockApollo([ + [moveIssueMutation, mutationResolverMock], + [getIssuesQuery, mockIssuesQueryResponse], + [getIssuesCountsQuery, mockIssuesCountsQueryResponse], + ]); + + fakeApollo.defaultClient.cache.writeQuery({ + query: getIssuesQuery, + variables: { + isProject: true, + fullPath: mockDefaultProps.projectFullPath, + }, + data: getIssuesQueryCompleteResponse.data, + }); + + fakeApollo.defaultClient.cache.writeQuery({ + query: getIssuesCountsQuery, + variables: { + isProject: true, + }, + data: getIssuesCountsQueryResponse.data, + }); + + wrapper = shallowMount(MoveIssuesButton, { + data() { + return { + ...data, + }; + }, + propsData: { + ...mockDefaultProps, + }, + apolloProvider: fakeApollo, + }); + }; + + beforeEach(() => { + // Needed due to a bug in Apollo: https://github.com/apollographql/apollo-client/issues/8900 + // eslint-disable-next-line no-console + console.warn = jest.fn(); + }); + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + describe('`Move selected` dropdown', () => { + it('renders disabled by default', () => { + createComponent(); + expect(findDropdown().exists()).toBe(true); + expect(findDropdown().attributes('disabled')).toBe('true'); + }); + + it.each` + selectedIssuablesMock | disabled | status | testMessage + ${[]} | ${true} | ${'disabled'} | ${'nothing is selected'} + ${selectedIssuesMocks.tasksOnly} | ${true} | ${'disabled'} | ${'only tasks are selected'} + ${selectedIssuesMocks.testCasesOnly} | ${true} | ${'disabled'} | ${'only test cases are selected'} + ${selectedIssuesMocks.issuesOnly} | ${false} | ${'enabled'} | ${'only issues are selected'} + ${selectedIssuesMocks.tasksAndTestCases} | ${true} | ${'disabled'} | ${'tasks and test cases are selected'} + ${selectedIssuesMocks.issuesAndTasks} | ${false} | ${'enabled'} | ${'issues and tasks are selected'} + ${selectedIssuesMocks.issuesAndTestCases} | ${false} | ${'enabled'} | ${'issues and test cases are selected'} + ${selectedIssuesMocks.issuesTasksAndTestCases} | ${false} | ${'enabled'} | ${'issues and tasks and test cases are selected'} + `('renders $status if $testMessage', async ({ selectedIssuablesMock, disabled }) => { + createComponent({ selectedIssuables: selectedIssuablesMock }); + + await nextTick(); + + if (disabled) { + expect(findDropdown().attributes('disabled')).toBe('true'); + } else { + expect(findDropdown().attributes('disabled')).toBeUndefined(); + } + }); + }); + + describe('warning message', () => { + it.each` + selectedIssuablesMock | warningExists | visibility | message | testMessage + ${[]} | ${false} | ${'not visible'} | ${'empty'} | ${'nothing is selected'} + ${selectedIssuesMocks.tasksOnly} | ${true} | ${'visible'} | ${'Tasks can not be moved.'} | ${'only tasks are selected'} + ${selectedIssuesMocks.testCasesOnly} | ${true} | ${'visible'} | ${'Test cases can not be moved.'} | ${'only test cases are selected'} + ${selectedIssuesMocks.issuesOnly} | ${false} | ${'not visible'} | ${'empty'} | ${'only issues are selected'} + ${selectedIssuesMocks.tasksAndTestCases} | ${true} | ${'visible'} | ${'Tasks and test cases can not be moved.'} | ${'tasks and test cases are selected'} + ${selectedIssuesMocks.issuesAndTasks} | ${true} | ${'visible'} | ${'Tasks can not be moved.'} | ${'issues and tasks are selected'} + ${selectedIssuesMocks.issuesAndTestCases} | ${true} | ${'visible'} | ${'Test cases can not be moved.'} | ${'issues and test cases are selected'} + ${selectedIssuesMocks.issuesTasksAndTestCases} | ${true} | ${'visible'} | ${'Tasks and test cases can not be moved.'} | ${'issues and tasks and test cases are selected'} + `( + 'is $visibility with `$message` message if $testMessage', + async ({ selectedIssuablesMock, warningExists, message }) => { + createComponent({ selectedIssuables: selectedIssuablesMock }); + + await nextTick(); + + const alert = findAlert(); + expect(alert.exists()).toBe(warningExists); + + if (warningExists) { + expect(alert.text()).toBe(message); + expect(alert.attributes('variant')).toBe('warning'); + } + }, + ); + }); + + describe('moveIssues method', () => { + describe('changes the `Move selected` dropdown loading state', () => { + it('keeps loading state to false when no issue is selected', async () => { + createComponent(); + emitMoveIssuablesEvent(); + + await nextTick(); + + expect(findDropdown().props('moveInProgress')).toBe(false); + }); + + it('keeps loading state to false when only tasks are selected', async () => { + createComponent({ selectedIssuables: selectedIssuesMocks.tasksOnly }); + emitMoveIssuablesEvent(); + + await nextTick(); + + expect(findDropdown().props('moveInProgress')).toBe(false); + }); + + it('keeps loading state to false when only test cases are selected', async () => { + createComponent({ selectedIssuables: selectedIssuesMocks.testCasesOnly }); + emitMoveIssuablesEvent(); + + await nextTick(); + + expect(findDropdown().props('moveInProgress')).toBe(false); + }); + + it('keeps loading state to false when only tasks and test cases are selected', async () => { + createComponent({ selectedIssuables: selectedIssuesMocks.tasksAndTestCases }); + emitMoveIssuablesEvent(); + + await nextTick(); + + expect(findDropdown().props('moveInProgress')).toBe(false); + }); + + it('sets loading state to true when issues are moving', async () => { + createComponent({ selectedIssuables: selectedIssuesMocks.issuesTasksAndTestCases }); + emitMoveIssuablesEvent(); + + await nextTick(); + + expect(findDropdown().props('moveInProgress')).toBe(true); + }); + + it('sets loading state to false when all mutations succeed', async () => { + createComponent( + { selectedIssuables: selectedIssuesMocks.issuesTasksAndTestCases }, + resolvedMutationWithoutErrorsMock, + ); + emitMoveIssuablesEvent(); + + await nextTick(); + await waitForPromises(); + + expect(findDropdown().props('moveInProgress')).toBe(false); + }); + + it('sets loading state to false when a mutation returns errors', async () => { + createComponent( + { selectedIssuables: selectedIssuesMocks.issuesTasksAndTestCases }, + resolvedMutationWithErrorsMock, + ); + emitMoveIssuablesEvent(); + + await nextTick(); + await waitForPromises(); + + expect(findDropdown().props('moveInProgress')).toBe(false); + }); + + it('sets loading state to false when a mutation is rejected', async () => { + createComponent({ selectedIssuables: selectedIssuesMocks.issuesTasksAndTestCases }); + emitMoveIssuablesEvent(); + + await nextTick(); + await waitForPromises(); + + expect(findDropdown().props('moveInProgress')).toBe(false); + }); + }); + + describe('handles events', () => { + beforeEach(() => { + jest.spyOn(issuableEventHub, '$emit'); + }); + + it('does not emit any event when no issue is selected', async () => { + createComponent(); + emitMoveIssuablesEvent(); + + await waitForPromises(); + + expect(issuableEventHub.$emit).not.toHaveBeenCalled(); + }); + + it('does not emit any event when only tasks are selected', async () => { + createComponent({ selectedIssuables: selectedIssuesMocks.tasksOnly }); + emitMoveIssuablesEvent(); + + await waitForPromises(); + + expect(issuableEventHub.$emit).not.toHaveBeenCalled(); + }); + + it('does not emit any event when only test cases are selected', async () => { + createComponent({ selectedIssuables: selectedIssuesMocks.testCasesOnly }); + emitMoveIssuablesEvent(); + + await waitForPromises(); + + expect(issuableEventHub.$emit).not.toHaveBeenCalled(); + }); + + it('does not emit any event when only tasks and test cases are selected', async () => { + createComponent({ selectedIssuables: selectedIssuesMocks.tasksAndTestCases }); + emitMoveIssuablesEvent(); + + await waitForPromises(); + + expect(issuableEventHub.$emit).not.toHaveBeenCalled(); + }); + + it('emits `issuables:bulkMoveStarted` when issues are moving', async () => { + createComponent({ selectedIssuables: selectedIssuesMocks.issuesTasksAndTestCases }); + emitMoveIssuablesEvent(); + + expect(issuableEventHub.$emit).toHaveBeenCalledWith('issuables:bulkMoveStarted'); + }); + + it('emits `issuables:bulkMoveEnded` when all mutations succeed', async () => { + createComponent( + { selectedIssuables: selectedIssuesMocks.issuesTasksAndTestCases }, + resolvedMutationWithoutErrorsMock, + ); + emitMoveIssuablesEvent(); + + await waitForPromises(); + + expect(issuableEventHub.$emit).toHaveBeenCalledWith('issuables:bulkMoveEnded'); + }); + + it('emits `issuables:bulkMoveEnded` when a mutation returns errors', async () => { + createComponent( + { selectedIssuables: selectedIssuesMocks.issuesTasksAndTestCases }, + resolvedMutationWithErrorsMock, + ); + emitMoveIssuablesEvent(); + + await waitForPromises(); + + expect(issuableEventHub.$emit).toHaveBeenCalledWith('issuables:bulkMoveEnded'); + }); + + it('emits `issuables:bulkMoveEnded` when a mutation is rejected', async () => { + createComponent({ selectedIssuables: selectedIssuesMocks.issuesTasksAndTestCases }); + emitMoveIssuablesEvent(); + + await waitForPromises(); + + expect(issuableEventHub.$emit).toHaveBeenCalledWith('issuables:bulkMoveEnded'); + }); + }); + + describe('shows errors', () => { + it('does not create flashes or logs errors when no issue is selected', async () => { + createComponent(); + emitMoveIssuablesEvent(); + + await waitForPromises(); + + expect(logError).not.toHaveBeenCalled(); + expect(createFlash).not.toHaveBeenCalled(); + }); + + it('does not create flashes or logs errors when only tasks are selected', async () => { + createComponent({ selectedIssuables: selectedIssuesMocks.tasksOnly }); + emitMoveIssuablesEvent(); + + await waitForPromises(); + + expect(logError).not.toHaveBeenCalled(); + expect(createFlash).not.toHaveBeenCalled(); + }); + + it('does not create flashes or logs errors when only test cases are selected', async () => { + createComponent({ selectedIssuables: selectedIssuesMocks.testCasesOnly }); + emitMoveIssuablesEvent(); + + await waitForPromises(); + + expect(logError).not.toHaveBeenCalled(); + expect(createFlash).not.toHaveBeenCalled(); + }); + + it('does not create flashes or logs errors when only tasks and test cases are selected', async () => { + createComponent({ selectedIssuables: selectedIssuesMocks.tasksAndTestCases }); + emitMoveIssuablesEvent(); + + await waitForPromises(); + + expect(logError).not.toHaveBeenCalled(); + expect(createFlash).not.toHaveBeenCalled(); + }); + + it('does not create flashes or logs errors when issues are moved without errors', async () => { + createComponent( + { selectedIssuables: selectedIssuesMocks.issuesTasksAndTestCases }, + resolvedMutationWithoutErrorsMock, + ); + emitMoveIssuablesEvent(); + + await waitForPromises(); + + expect(logError).not.toHaveBeenCalled(); + expect(createFlash).not.toHaveBeenCalled(); + }); + + it('creates a flash and logs errors when a mutation returns errors', async () => { + createComponent( + { selectedIssuables: selectedIssuesMocks.issuesTasksAndTestCases }, + resolvedMutationWithErrorsMock, + ); + emitMoveIssuablesEvent(); + + await waitForPromises(); + + // We're mocking two issues so it will log two errors + expect(logError).toHaveBeenCalledTimes(2); + expect(logError).toHaveBeenNthCalledWith( + 1, + `Error moving issue. Error message: ${mockMutationErrorMessage}`, + ); + expect(logError).toHaveBeenNthCalledWith( + 2, + `Error moving issue. Error message: ${mockMutationErrorMessage}`, + ); + + // Only one flash is created even if multiple errors are reported + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was an error while moving the issues.', + }); + }); + + it('creates a flash but not logs errors when a mutation is rejected', async () => { + createComponent({ selectedIssuables: selectedIssuesMocks.issuesTasksAndTestCases }); + emitMoveIssuablesEvent(); + + await waitForPromises(); + + expect(logError).not.toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was an error while moving the issues.', + }); + }); + }); + + describe('calls mutations', () => { + it('does not call any mutation when no issue is selected', async () => { + createComponent({}, resolvedMutationWithoutErrorsMock); + emitMoveIssuablesEvent(); + + await waitForPromises(); + + expect(resolvedMutationWithoutErrorsMock).not.toHaveBeenCalled(); + }); + + it('does not call any mutation when only tasks are selected', async () => { + createComponent( + { selectedIssuables: selectedIssuesMocks.tasksOnly }, + resolvedMutationWithoutErrorsMock, + ); + emitMoveIssuablesEvent(); + + await waitForPromises(); + + expect(resolvedMutationWithoutErrorsMock).not.toHaveBeenCalled(); + }); + + it('does not call any mutation when only test cases are selected', async () => { + createComponent( + { selectedIssuables: selectedIssuesMocks.testCasesOnly }, + resolvedMutationWithoutErrorsMock, + ); + emitMoveIssuablesEvent(); + + await waitForPromises(); + + expect(resolvedMutationWithoutErrorsMock).not.toHaveBeenCalled(); + }); + + it('does not call any mutation when only tasks and test cases are selected', async () => { + createComponent( + { selectedIssuables: selectedIssuesMocks.tasksAndTestCases }, + resolvedMutationWithoutErrorsMock, + ); + emitMoveIssuablesEvent(); + + await waitForPromises(); + + expect(resolvedMutationWithoutErrorsMock).not.toHaveBeenCalled(); + }); + + it('calls a mutation for every selected issue skipping tasks', async () => { + createComponent( + { selectedIssuables: selectedIssuesMocks.issuesTasksAndTestCases }, + resolvedMutationWithoutErrorsMock, + ); + emitMoveIssuablesEvent(); + + await waitForPromises(); + + // We mock three elements but only two are valid issues since the task is skipped + expect(resolvedMutationWithoutErrorsMock).toHaveBeenCalledTimes(2); + expect(resolvedMutationWithoutErrorsMock).toHaveBeenNthCalledWith(1, { + moveIssueInput: { + projectPath: mockDefaultProps.projectFullPath, + iid: selectedIssuesMocks.issuesTasksAndTestCases[0].iid.toString(), + targetProjectPath: mockDestinationProject.full_path, + }, + }); + + expect(resolvedMutationWithoutErrorsMock).toHaveBeenNthCalledWith(2, { + moveIssueInput: { + projectPath: mockDefaultProps.projectFullPath, + iid: selectedIssuesMocks.issuesTasksAndTestCases[1].iid.toString(), + targetProjectPath: mockDestinationProject.full_path, + }, + }); + }); + }); + }); +}); |