diff options
Diffstat (limited to 'spec/frontend/sidebar')
64 files changed, 5144 insertions, 132 deletions
diff --git a/spec/frontend/sidebar/assignee_title_spec.js b/spec/frontend/sidebar/components/assignees/assignee_title_spec.js index 14a6bdbf907..14a6bdbf907 100644 --- a/spec/frontend/sidebar/assignee_title_spec.js +++ b/spec/frontend/sidebar/components/assignees/assignee_title_spec.js diff --git a/spec/frontend/sidebar/assignees_realtime_spec.js b/spec/frontend/sidebar/components/assignees/assignees_realtime_spec.js index ae8f07bf901..080171fb2ea 100644 --- a/spec/frontend/sidebar/assignees_realtime_spec.js +++ b/spec/frontend/sidebar/components/assignees/assignees_realtime_spec.js @@ -6,12 +6,12 @@ import waitForPromises from 'helpers/wait_for_promises'; import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql'; import SidebarMediator from '~/sidebar/sidebar_mediator'; -import getIssueAssigneesQuery from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql'; +import getIssueAssigneesQuery from '~/sidebar/queries/get_issue_assignees.query.graphql'; import Mock, { issuableQueryResponse, subscriptionNullResponse, subscriptionResponse, -} from './mock_data'; +} from '../../mock_data'; Vue.use(VueApollo); diff --git a/spec/frontend/sidebar/assignees_spec.js b/spec/frontend/sidebar/components/assignees/assignees_spec.js index 7cf7fd33022..6971ae2f9ed 100644 --- a/spec/frontend/sidebar/assignees_spec.js +++ b/spec/frontend/sidebar/components/assignees/assignees_spec.js @@ -5,7 +5,7 @@ import { trimText } from 'helpers/text_helper'; import UsersMockHelper from 'helpers/user_mock_data_helper'; import Assignee from '~/sidebar/components/assignees/assignees.vue'; import AssigneeAvatarLink from '~/sidebar/components/assignees/assignee_avatar_link.vue'; -import UsersMock from './mock_data'; +import UsersMock from '../../mock_data'; describe('Assignee component', () => { const getDefaultProps = () => ({ diff --git a/spec/frontend/sidebar/issuable_assignees_spec.js b/spec/frontend/sidebar/components/assignees/issuable_assignees_spec.js index 1161fefcc64..1161fefcc64 100644 --- a/spec/frontend/sidebar/issuable_assignees_spec.js +++ b/spec/frontend/sidebar/components/assignees/issuable_assignees_spec.js diff --git a/spec/frontend/sidebar/sidebar_assignees_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_assignees_spec.js index 2cb2425532b..58b174059fa 100644 --- a/spec/frontend/sidebar/sidebar_assignees_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_assignees_spec.js @@ -8,7 +8,7 @@ import SidebarAssignees from '~/sidebar/components/assignees/sidebar_assignees.v import SidebarService from '~/sidebar/services/sidebar_service'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarStore from '~/sidebar/stores/sidebar_store'; -import Mock from './mock_data'; +import Mock from '../../mock_data'; describe('sidebar assignees', () => { let wrapper; diff --git a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js index cbb4c41dd14..3aca346ff5f 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js @@ -12,8 +12,8 @@ import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarInviteMembers from '~/sidebar/components/assignees/sidebar_invite_members.vue'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import getIssueAssigneesQuery from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql'; -import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql'; +import getIssueAssigneesQuery from '~/sidebar/queries/get_issue_assignees.query.graphql'; +import updateIssueAssigneesMutation from '~/sidebar/queries/update_issue_assignees.mutation.graphql'; import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; import { issuableQueryResponse, updateIssueAssigneesMutationResponse } from '../../mock_data'; diff --git a/spec/frontend/sidebar/components/copy_email_to_clipboard_spec.js b/spec/frontend/sidebar/components/copy/copy_email_to_clipboard_spec.js index 69a8d645973..5b6db43a366 100644 --- a/spec/frontend/sidebar/components/copy_email_to_clipboard_spec.js +++ b/spec/frontend/sidebar/components/copy/copy_email_to_clipboard_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import CopyEmailToClipboard from '~/sidebar/components/copy_email_to_clipboard.vue'; -import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue'; +import CopyEmailToClipboard from '~/sidebar/components/copy/copy_email_to_clipboard.vue'; +import CopyableField from '~/sidebar/components/copy/copyable_field.vue'; describe('CopyEmailToClipboard component', () => { const mockIssueEmailAddress = 'sample+email@test.com'; diff --git a/spec/frontend/sidebar/components/copy/copyable_field_spec.js b/spec/frontend/sidebar/components/copy/copyable_field_spec.js new file mode 100644 index 00000000000..7790d77bc65 --- /dev/null +++ b/spec/frontend/sidebar/components/copy/copyable_field_spec.js @@ -0,0 +1,77 @@ +import { GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import CopyableField from '~/sidebar/components/copy/copyable_field.vue'; + +describe('SidebarCopyableField', () => { + let wrapper; + + const defaultProps = { + value: 'Gl-1', + name: 'Reference', + }; + + const createComponent = (propsData = defaultProps) => { + wrapper = shallowMount(CopyableField, { + propsData, + stubs: { + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findClipboardButton = () => wrapper.findComponent(ClipboardButton); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + describe('template', () => { + describe('when `isLoading` prop is `false`', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders copyable field', () => { + expect(wrapper.text()).toContain('Reference: Gl-1'); + }); + + it('renders ClipboardButton with correct props', () => { + const clipboardButton = findClipboardButton(); + + expect(clipboardButton.exists()).toBe(true); + expect(clipboardButton.props('title')).toBe(`Copy ${defaultProps.name}`); + expect(clipboardButton.props('text')).toBe(defaultProps.value); + }); + + it('does not render loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('when `isLoading` prop is `true`', () => { + beforeEach(() => { + createComponent({ ...defaultProps, isLoading: true }); + }); + + it('renders loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(findLoadingIcon().props('label')).toBe('Loading Reference'); + }); + + it('does not render clipboard button', () => { + expect(findClipboardButton().exists()).toBe(false); + }); + }); + + describe('with `clipboardTooltipText` prop', () => { + it('sets ClipboardButton `title` prop to `clipboardTooltipText` value', () => { + const mockClipboardTooltipText = 'Copy my custom value'; + createComponent({ ...defaultProps, clipboardTooltipText: mockClipboardTooltipText }); + + expect(findClipboardButton().props('title')).toBe(mockClipboardTooltipText); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js b/spec/frontend/sidebar/components/copy/sidebar_reference_widget_spec.js index 69e35cd1d05..c5161a748a9 100644 --- a/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js +++ b/spec/frontend/sidebar/components/copy/sidebar_reference_widget_spec.js @@ -4,10 +4,10 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { IssuableType } from '~/issues/constants'; -import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue'; +import SidebarReferenceWidget from '~/sidebar/components/copy/sidebar_reference_widget.vue'; import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql'; import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql'; -import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue'; +import CopyableField from '~/sidebar/components/copy/copyable_field.vue'; import { issueReferenceResponse } from '../../mock_data'; describe('Sidebar Reference Widget', () => { diff --git a/spec/frontend/sidebar/components/crm_contacts_spec.js b/spec/frontend/sidebar/components/crm_contacts/crm_contacts_spec.js index 6d76fa1f9df..ca43c219d92 100644 --- a/spec/frontend/sidebar/components/crm_contacts_spec.js +++ b/spec/frontend/sidebar/components/crm_contacts/crm_contacts_spec.js @@ -5,13 +5,13 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; import CrmContacts from '~/sidebar/components/crm_contacts/crm_contacts.vue'; -import getIssueCrmContactsQuery from '~/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql'; -import issueCrmContactsSubscription from '~/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql'; +import getIssueCrmContactsQuery from '~/sidebar/queries/get_issue_crm_contacts.query.graphql'; +import issueCrmContactsSubscription from '~/sidebar/queries/issue_crm_contacts.subscription.graphql'; import { getIssueCrmContactsQueryResponse, issueCrmContactsUpdateResponse, issueCrmContactsUpdateNullResponse, -} from './mock_data'; +} from '../mock_data'; jest.mock('~/flash'); diff --git a/spec/frontend/sidebar/components/incidents/escalation_status_spec.js b/spec/frontend/sidebar/components/incidents/escalation_status_spec.js index 83764cb6739..1a78ce4ddee 100644 --- a/spec/frontend/sidebar/components/incidents/escalation_status_spec.js +++ b/spec/frontend/sidebar/components/incidents/escalation_status_spec.js @@ -3,11 +3,7 @@ import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import EscalationStatus from '~/sidebar/components/incidents/escalation_status.vue'; -import { - STATUS_LABELS, - STATUS_TRIGGERED, - STATUS_ACKNOWLEDGED, -} from '~/sidebar/components/incidents/constants'; +import { STATUS_LABELS, STATUS_TRIGGERED, STATUS_ACKNOWLEDGED } from '~/sidebar/constants'; describe('EscalationStatus', () => { let wrapper; diff --git a/spec/frontend/sidebar/components/incidents/escalation_utils_spec.js b/spec/frontend/sidebar/components/incidents/escalation_utils_spec.js index edd65db0325..d9e7f29c10e 100644 --- a/spec/frontend/sidebar/components/incidents/escalation_utils_spec.js +++ b/spec/frontend/sidebar/components/incidents/escalation_utils_spec.js @@ -1,5 +1,5 @@ -import { STATUS_ACKNOWLEDGED } from '~/sidebar/components/incidents/constants'; -import { getStatusLabel } from '~/sidebar/components/incidents/utils'; +import { STATUS_ACKNOWLEDGED } from '~/sidebar/constants'; +import { getStatusLabel } from '~/sidebar/utils'; describe('EscalationUtils', () => { describe('getStatusLabel', () => { diff --git a/spec/frontend/sidebar/components/incidents/mock_data.js b/spec/frontend/sidebar/components/incidents/mock_data.js index bbb6c61b162..2a5b7798110 100644 --- a/spec/frontend/sidebar/components/incidents/mock_data.js +++ b/spec/frontend/sidebar/components/incidents/mock_data.js @@ -1,4 +1,4 @@ -import { STATUS_TRIGGERED, STATUS_ACKNOWLEDGED } from '~/sidebar/components/incidents/constants'; +import { STATUS_TRIGGERED, STATUS_ACKNOWLEDGED } from '~/sidebar/constants'; export const fetchData = { workspace: { diff --git a/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js b/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js index 88a4913a27f..2dded61c073 100644 --- a/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js +++ b/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js @@ -1,5 +1,4 @@ -import { createLocalVue } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { fetchData, @@ -12,26 +11,28 @@ import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import SidebarEscalationStatus from '~/sidebar/components/incidents/sidebar_escalation_status.vue'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import { escalationStatusQuery, escalationStatusMutation } from '~/sidebar/constants'; +import { + escalationStatusQuery, + escalationStatusMutation, + STATUS_ACKNOWLEDGED, +} from '~/sidebar/constants'; import waitForPromises from 'helpers/wait_for_promises'; import EscalationStatus from 'ee_else_ce/sidebar/components/incidents/escalation_status.vue'; -import { STATUS_ACKNOWLEDGED } from '~/sidebar/components/incidents/constants'; import { createAlert } from '~/flash'; import { logError } from '~/lib/logger'; jest.mock('~/lib/logger'); jest.mock('~/flash'); -const localVue = createLocalVue(); +Vue.use(VueApollo); describe('SidebarEscalationStatus', () => { let wrapper; + let mockApollo; const queryResolverMock = jest.fn(); const mutationResolverMock = jest.fn(); function createMockApolloProvider({ hasFetchError = false, hasMutationError = false } = {}) { - localVue.use(VueApollo); - queryResolverMock.mockResolvedValue({ data: hasFetchError ? fetchError : fetchData }); mutationResolverMock.mockResolvedValue({ data: hasMutationError ? mutationError : mutationData, @@ -45,15 +46,7 @@ describe('SidebarEscalationStatus', () => { return createMockApollo(requestHandlers); } - function createComponent({ mockApollo } = {}) { - let config; - - if (mockApollo) { - config = { apolloProvider: mockApollo }; - } else { - config = { mocks: { $apollo: { queries: { status: { loading: false } } } } }; - } - + function createComponent(apolloProvider) { wrapper = mountExtended(SidebarEscalationStatus, { propsData: { iid: '1', @@ -66,13 +59,15 @@ describe('SidebarEscalationStatus', () => { directives: { GlTooltip: createMockDirective(), }, - localVue, - ...config, + apolloProvider, }); + + // wait for apollo requests + return waitForPromises(); } - afterEach(() => { - wrapper.destroy(); + beforeEach(() => { + mockApollo = createMockApolloProvider(); }); const findSidebarComponent = () => wrapper.findComponent(SidebarEditableItem); @@ -80,36 +75,32 @@ describe('SidebarEscalationStatus', () => { const findEditButton = () => wrapper.findByTestId('edit-button'); const findIcon = () => wrapper.findByTestId('status-icon'); - const clickEditButton = async () => { + const clickEditButton = () => { findEditButton().vm.$emit('click'); - await nextTick(); + return nextTick(); }; - const selectAcknowledgedStatus = async () => { + const selectAcknowledgedStatus = () => { findStatusComponent().vm.$emit('input', STATUS_ACKNOWLEDGED); // wait for apollo requests - await waitForPromises(); + return waitForPromises(); }; describe('sidebar', () => { - it('renders the sidebar component', () => { - createComponent(); + it('renders the sidebar component', async () => { + await createComponent(mockApollo); expect(findSidebarComponent().exists()).toBe(true); }); describe('status icon', () => { - it('is visible', () => { - createComponent(); + it('is visible', async () => { + await createComponent(mockApollo); expect(findIcon().exists()).toBe(true); expect(findIcon().isVisible()).toBe(true); }); it('has correct tooltip', async () => { - const mockApollo = createMockApolloProvider(); - createComponent({ mockApollo }); - - // wait for apollo requests - await waitForPromises(); + await createComponent(mockApollo); const tooltip = getBinding(findIcon().element, 'gl-tooltip'); @@ -120,11 +111,7 @@ describe('SidebarEscalationStatus', () => { describe('status dropdown', () => { beforeEach(async () => { - const mockApollo = createMockApolloProvider(); - createComponent({ mockApollo }); - - // wait for apollo requests - await waitForPromises(); + await createComponent(mockApollo); }); it('is closed by default', () => { @@ -148,11 +135,7 @@ describe('SidebarEscalationStatus', () => { describe('update Status event', () => { beforeEach(async () => { - const mockApollo = createMockApolloProvider(); - createComponent({ mockApollo }); - - // wait for apollo requests - await waitForPromises(); + await createComponent(mockApollo); await clickEditButton(); await selectAcknowledgedStatus(); @@ -184,22 +167,16 @@ describe('SidebarEscalationStatus', () => { describe('mutation errors', () => { it('should error upon fetch', async () => { - const mockApollo = createMockApolloProvider({ hasFetchError: true }); - createComponent({ mockApollo }); - - // wait for apollo requests - await waitForPromises(); + mockApollo = createMockApolloProvider({ hasFetchError: true }); + await createComponent(mockApollo); expect(createAlert).toHaveBeenCalled(); expect(logError).toHaveBeenCalled(); }); it('should error upon mutation', async () => { - const mockApollo = createMockApolloProvider({ hasMutationError: true }); - createComponent({ mockApollo }); - - // wait for apollo requests - await waitForPromises(); + mockApollo = createMockApolloProvider({ hasMutationError: true }); + await createComponent(mockApollo); await clickEditButton(); await selectAcknowledgedStatus(); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_button_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_button_spec.js new file mode 100644 index 00000000000..4f2a89e20db --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_button_spec.js @@ -0,0 +1,89 @@ +import { GlIcon, GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; + +import DropdownButton from '~/sidebar/components/labels/labels_select_vue/dropdown_button.vue'; + +import labelSelectModule from '~/sidebar/components/labels/labels_select_vue/store'; + +import { mockConfig } from './mock_data'; + +let store; +Vue.use(Vuex); + +const createComponent = (initialState = mockConfig) => { + store = new Vuex.Store(labelSelectModule()); + + store.dispatch('setInitialState', initialState); + + return shallowMount(DropdownButton, { + store, + }); +}; + +describe('DropdownButton', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findDropdownButton = () => wrapper.findComponent(GlButton); + const findDropdownText = () => wrapper.find('.dropdown-toggle-text'); + const findDropdownIcon = () => wrapper.findComponent(GlIcon); + + describe('methods', () => { + describe('handleButtonClick', () => { + it.each` + variant | expectPropagationStopped + ${'standalone'} | ${true} + ${'embedded'} | ${false} + `( + 'toggles dropdown content and handles event propagation when `state.variant` is "$variant"', + ({ variant, expectPropagationStopped }) => { + const event = { stopPropagation: jest.fn() }; + + wrapper = createComponent({ ...mockConfig, variant }); + + findDropdownButton().vm.$emit('click', event); + + expect(store.state.showDropdownContents).toBe(true); + expect(event.stopPropagation).toHaveBeenCalledTimes(expectPropagationStopped ? 1 : 0); + }, + ); + }); + }); + + describe('template', () => { + it('renders component container element', () => { + expect(wrapper.findComponent(GlButton).element).toBe(wrapper.element); + }); + + it('renders default button text element', () => { + const dropdownTextEl = findDropdownText(); + + expect(dropdownTextEl.exists()).toBe(true); + expect(dropdownTextEl.text()).toBe('Label'); + }); + + it('renders provided button text element', async () => { + store.state.dropdownButtonText = 'Custom label'; + const dropdownTextEl = findDropdownText(); + + await nextTick(); + expect(dropdownTextEl.text()).toBe('Custom label'); + }); + + it('renders chevron icon element', () => { + const iconEl = findDropdownIcon(); + + expect(iconEl.exists()).toBe(true); + expect(iconEl.props('name')).toBe('chevron-down'); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js new file mode 100644 index 00000000000..59e95edfa20 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js @@ -0,0 +1,211 @@ +import { GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; + +import DropdownContentsCreateView from '~/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue'; + +import labelSelectModule from '~/sidebar/components/labels/labels_select_vue/store'; + +import { mockConfig, mockSuggestedColors } from './mock_data'; + +Vue.use(Vuex); + +const createComponent = (initialState = mockConfig) => { + const store = new Vuex.Store(labelSelectModule()); + + store.dispatch('setInitialState', initialState); + + return shallowMount(DropdownContentsCreateView, { + store, + }); +}; + +describe('DropdownContentsCreateView', () => { + let wrapper; + const colors = Object.keys(mockSuggestedColors).map((color) => ({ + [color]: mockSuggestedColors[color], + })); + + beforeEach(() => { + gon.suggested_label_colors = mockSuggestedColors; + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('computed', () => { + describe('disableCreate', () => { + it('returns `true` when label title and color is not defined', () => { + expect(wrapper.vm.disableCreate).toBe(true); + }); + + it('returns `true` when `labelCreateInProgress` is true', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + labelTitle: 'Foo', + selectedColor: '#ff0000', + }); + wrapper.vm.$store.dispatch('requestCreateLabel'); + + await nextTick(); + expect(wrapper.vm.disableCreate).toBe(true); + }); + + it('returns `false` when label title and color is defined and create request is not already in progress', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + labelTitle: 'Foo', + selectedColor: '#ff0000', + }); + + await nextTick(); + expect(wrapper.vm.disableCreate).toBe(false); + }); + }); + + describe('suggestedColors', () => { + it('returns array of color objects containing color code and name', () => { + colors.forEach((color, index) => { + expect(wrapper.vm.suggestedColors[index]).toEqual(expect.objectContaining(color)); + }); + }); + }); + }); + + describe('methods', () => { + describe('getColorCode', () => { + it('returns color code from color object', () => { + expect(wrapper.vm.getColorCode(colors[0])).toBe(Object.keys(colors[0]).pop()); + }); + }); + + describe('getColorName', () => { + it('returns color name from color object', () => { + expect(wrapper.vm.getColorName(colors[0])).toBe(Object.values(colors[0]).pop()); + }); + }); + + describe('handleColorClick', () => { + it('sets provided `color` param to `selectedColor` prop', () => { + wrapper.vm.handleColorClick(colors[0]); + + expect(wrapper.vm.selectedColor).toBe(Object.keys(colors[0]).pop()); + }); + }); + + describe('handleCreateClick', () => { + it('calls action `createLabel` with object containing `labelTitle` & `selectedColor`', async () => { + jest.spyOn(wrapper.vm, 'createLabel').mockImplementation(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + labelTitle: 'Foo', + selectedColor: '#ff0000', + }); + + wrapper.vm.handleCreateClick(); + + await nextTick(); + expect(wrapper.vm.createLabel).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Foo', + color: '#ff0000', + }), + ); + }); + }); + }); + + describe('template', () => { + it('renders component container element with class "labels-select-contents-create"', () => { + expect(wrapper.attributes('class')).toContain('labels-select-contents-create'); + }); + + it('renders dropdown back button element', () => { + const backBtnEl = wrapper.find('.dropdown-title').findAllComponents(GlButton).at(0); + + expect(backBtnEl.exists()).toBe(true); + expect(backBtnEl.attributes('aria-label')).toBe('Go back'); + expect(backBtnEl.props('icon')).toBe('arrow-left'); + }); + + it('renders dropdown title element', () => { + const headerEl = wrapper.find('.dropdown-title > span'); + + expect(headerEl.exists()).toBe(true); + expect(headerEl.text()).toBe('Create label'); + }); + + it('renders dropdown close button element', () => { + const closeBtnEl = wrapper.find('.dropdown-title').findAllComponents(GlButton).at(1); + + expect(closeBtnEl.exists()).toBe(true); + expect(closeBtnEl.attributes('aria-label')).toBe('Close'); + expect(closeBtnEl.props('icon')).toBe('close'); + }); + + it('renders label title input element', () => { + const titleInputEl = wrapper.find('.dropdown-input').findComponent(GlFormInput); + + expect(titleInputEl.exists()).toBe(true); + expect(titleInputEl.attributes('placeholder')).toBe('Name new label'); + expect(titleInputEl.attributes('autofocus')).toBe('true'); + }); + + it('renders color block element for all suggested colors', () => { + const colorBlocksEl = wrapper.find('.dropdown-content').findAllComponents(GlLink); + + colorBlocksEl.wrappers.forEach((colorBlock, index) => { + expect(colorBlock.attributes('style')).toContain('background-color'); + expect(colorBlock.attributes('title')).toBe(Object.values(colors[index]).pop()); + }); + }); + + it('renders color input element', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + selectedColor: '#ff0000', + }); + + await nextTick(); + const colorPreviewEl = wrapper.find('.color-input-container > .dropdown-label-color-preview'); + const colorInputEl = wrapper.find('.color-input-container').findComponent(GlFormInput); + + expect(colorPreviewEl.exists()).toBe(true); + expect(colorPreviewEl.attributes('style')).toContain('background-color'); + expect(colorInputEl.exists()).toBe(true); + expect(colorInputEl.attributes('placeholder')).toBe('Use custom color #FF0000'); + expect(colorInputEl.attributes('value')).toBe('#ff0000'); + }); + + it('renders create button element', () => { + const createBtnEl = wrapper.find('.dropdown-actions').findAllComponents(GlButton).at(0); + + expect(createBtnEl.exists()).toBe(true); + expect(createBtnEl.text()).toContain('Create'); + }); + + it('shows gl-loading-icon within create button element when `labelCreateInProgress` is `true`', async () => { + wrapper.vm.$store.dispatch('requestCreateLabel'); + + await nextTick(); + const loadingIconEl = wrapper.find('.dropdown-actions').findComponent(GlLoadingIcon); + + expect(loadingIconEl.exists()).toBe(true); + expect(loadingIconEl.isVisible()).toBe(true); + }); + + it('renders cancel button element', () => { + const cancelBtnEl = wrapper.find('.dropdown-actions').findAllComponents(GlButton).at(1); + + expect(cancelBtnEl.exists()).toBe(true); + expect(cancelBtnEl.text()).toContain('Cancel'); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view_spec.js new file mode 100644 index 00000000000..865dc8fe8fb --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -0,0 +1,413 @@ +import { + GlIntersectionObserver, + GlButton, + GlLoadingIcon, + GlSearchBoxByType, + GlLink, +} from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; +import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; +import DropdownContentsLabelsView from '~/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue'; +import LabelItem from '~/sidebar/components/labels/labels_select_vue/label_item.vue'; + +import * as actions from '~/sidebar/components/labels/labels_select_vue/store/actions'; +import * as getters from '~/sidebar/components/labels/labels_select_vue/store/getters'; +import mutations from '~/sidebar/components/labels/labels_select_vue/store/mutations'; +import defaultState from '~/sidebar/components/labels/labels_select_vue/store/state'; + +import { mockConfig, mockLabels, mockRegularLabel } from './mock_data'; + +Vue.use(Vuex); + +describe('DropdownContentsLabelsView', () => { + let wrapper; + + const createComponent = (initialState = mockConfig) => { + const store = new Vuex.Store({ + getters, + mutations, + state: { + ...defaultState(), + footerCreateLabelTitle: 'Create label', + footerManageLabelTitle: 'Manage labels', + }, + actions: { + ...actions, + fetchLabels: jest.fn(), + }, + }); + + store.dispatch('setInitialState', initialState); + store.dispatch('receiveLabelsSuccess', mockLabels); + + wrapper = shallowMount(DropdownContentsLabelsView, { + store, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findDropdownContent = () => wrapper.find('[data-testid="dropdown-content"]'); + const findDropdownTitle = () => wrapper.find('[data-testid="dropdown-title"]'); + const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]'); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + describe('computed', () => { + describe('visibleLabels', () => { + it('returns matching labels filtered with `searchKey`', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + searchKey: 'bug', + }); + + expect(wrapper.vm.visibleLabels.length).toBe(1); + expect(wrapper.vm.visibleLabels[0].title).toBe('Bug'); + }); + + it('returns matching labels with fuzzy filtering', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + searchKey: 'bg', + }); + + expect(wrapper.vm.visibleLabels.length).toBe(2); + expect(wrapper.vm.visibleLabels[0].title).toBe('Bug'); + expect(wrapper.vm.visibleLabels[1].title).toBe('Boog'); + }); + + it('returns all labels when `searchKey` is empty', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + searchKey: '', + }); + + expect(wrapper.vm.visibleLabels.length).toBe(mockLabels.length); + }); + }); + + describe('showNoMatchingResultsMessage', () => { + it.each` + searchKey | labels | labelsDescription | returnValue + ${''} | ${[]} | ${'empty'} | ${false} + ${'bug'} | ${[]} | ${'empty'} | ${true} + ${''} | ${mockLabels} | ${'not empty'} | ${false} + ${'bug'} | ${mockLabels} | ${'not empty'} | ${false} + `( + 'returns $returnValue when searchKey is "$searchKey" and visibleLabels is $labelsDescription', + async ({ searchKey, labels, returnValue }) => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + searchKey, + }); + + wrapper.vm.$store.dispatch('receiveLabelsSuccess', labels); + + await nextTick(); + + expect(wrapper.vm.showNoMatchingResultsMessage).toBe(returnValue); + }, + ); + }); + }); + + describe('methods', () => { + const fakePreventDefault = jest.fn(); + + describe('isLabelSelected', () => { + it('returns true when provided `label` param is one of the selected labels', () => { + expect(wrapper.vm.isLabelSelected(mockRegularLabel)).toBe(true); + }); + + it('returns false when provided `label` param is not one of the selected labels', () => { + expect(wrapper.vm.isLabelSelected(mockLabels[1])).toBe(false); + }); + }); + + describe('handleComponentAppear', () => { + it('calls `focusInput` on searchInput field', async () => { + wrapper.vm.$refs.searchInput.focusInput = jest.fn(); + + await wrapper.vm.handleComponentAppear(); + + expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled(); + }); + }); + + describe('handleComponentDisappear', () => { + it('calls action `receiveLabelsSuccess` with empty array', () => { + jest.spyOn(wrapper.vm, 'receiveLabelsSuccess'); + + wrapper.vm.handleComponentDisappear(); + + expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]); + }); + }); + + describe('handleCreateLabelClick', () => { + it('calls actions `receiveLabelsSuccess` with empty array and `toggleDropdownContentsCreateView`', () => { + jest.spyOn(wrapper.vm, 'receiveLabelsSuccess'); + jest.spyOn(wrapper.vm, 'toggleDropdownContentsCreateView'); + + wrapper.vm.handleCreateLabelClick(); + + expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]); + expect(wrapper.vm.toggleDropdownContentsCreateView).toHaveBeenCalled(); + }); + }); + + describe('handleKeyDown', () => { + it('decreases `currentHighlightItem` value by 1 when Up arrow key is pressed', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + currentHighlightItem: 1, + }); + + wrapper.vm.handleKeyDown({ + keyCode: UP_KEY_CODE, + }); + + expect(wrapper.vm.currentHighlightItem).toBe(0); + }); + + it('increases `currentHighlightItem` value by 1 when Down arrow key is pressed', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + currentHighlightItem: 1, + }); + + wrapper.vm.handleKeyDown({ + keyCode: DOWN_KEY_CODE, + }); + + expect(wrapper.vm.currentHighlightItem).toBe(2); + }); + + it('resets the search text when the Enter key is pressed', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + currentHighlightItem: 1, + searchKey: 'bug', + }); + + wrapper.vm.handleKeyDown({ + keyCode: ENTER_KEY_CODE, + preventDefault: fakePreventDefault, + }); + + expect(wrapper.vm.searchKey).toBe(''); + expect(fakePreventDefault).toHaveBeenCalled(); + }); + + it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => { + jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + currentHighlightItem: 2, + }); + + wrapper.vm.handleKeyDown({ + keyCode: ENTER_KEY_CODE, + preventDefault: fakePreventDefault, + }); + + expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([mockLabels[2]]); + }); + + it('calls action `toggleDropdownContents` when Esc key is pressed', () => { + jest.spyOn(wrapper.vm, 'toggleDropdownContents').mockImplementation(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + currentHighlightItem: 1, + }); + + wrapper.vm.handleKeyDown({ + keyCode: ESC_KEY_CODE, + }); + + expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled(); + }); + + it('calls action `scrollIntoViewIfNeeded` in next tick when any key is pressed', async () => { + jest.spyOn(wrapper.vm, 'scrollIntoViewIfNeeded').mockImplementation(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + currentHighlightItem: 1, + }); + + wrapper.vm.handleKeyDown({ + keyCode: DOWN_KEY_CODE, + }); + + await nextTick(); + expect(wrapper.vm.scrollIntoViewIfNeeded).toHaveBeenCalled(); + }); + }); + + describe('handleLabelClick', () => { + beforeEach(() => { + jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation(); + }); + + it('calls action `updateSelectedLabels` with provided `label` param', () => { + wrapper.vm.handleLabelClick(mockRegularLabel); + + expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([mockRegularLabel]); + }); + + it('calls action `toggleDropdownContents` when `state.allowMultiselect` is false', () => { + jest.spyOn(wrapper.vm, 'toggleDropdownContents'); + wrapper.vm.$store.state.allowMultiselect = false; + + wrapper.vm.handleLabelClick(mockRegularLabel); + + expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled(); + }); + }); + }); + + describe('template', () => { + it('renders gl-intersection-observer as component root', () => { + expect(wrapper.findComponent(GlIntersectionObserver).exists()).toBe(true); + }); + + it('renders gl-loading-icon component when `labelsFetchInProgress` prop is true', async () => { + wrapper.vm.$store.dispatch('requestLabels'); + + await nextTick(); + const loadingIconEl = findLoadingIcon(); + + expect(loadingIconEl.exists()).toBe(true); + expect(loadingIconEl.attributes('class')).toContain('labels-fetch-loading'); + }); + + it('renders dropdown title element', () => { + const titleEl = findDropdownTitle(); + + expect(titleEl.exists()).toBe(true); + expect(titleEl.text()).toBe('Assign labels'); + }); + + it('does not render dropdown title element when `state.variant` is "standalone"', () => { + createComponent({ ...mockConfig, variant: 'standalone' }); + expect(findDropdownTitle().exists()).toBe(false); + }); + + it('renders dropdown title element when `state.variant` is "embedded"', () => { + createComponent({ ...mockConfig, variant: 'embedded' }); + expect(findDropdownTitle().exists()).toBe(true); + }); + + it('renders dropdown close button element', () => { + const closeButtonEl = findDropdownTitle().findComponent(GlButton); + + expect(closeButtonEl.exists()).toBe(true); + expect(closeButtonEl.props('icon')).toBe('close'); + }); + + it('renders label search input element', () => { + const searchInputEl = wrapper.findComponent(GlSearchBoxByType); + + expect(searchInputEl.exists()).toBe(true); + }); + + it('renders label elements for all labels', () => { + expect(wrapper.findAllComponents(LabelItem)).toHaveLength(mockLabels.length); + }); + + it('renders label element with `highlight` set to true when value of `currentHighlightItem` is more than -1', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + currentHighlightItem: 0, + }); + + await nextTick(); + const labelItemEl = findDropdownContent().findComponent(LabelItem); + + expect(labelItemEl.attributes('highlight')).toBe('true'); + }); + + it('renders element containing "No matching results" when `searchKey` does not match with any label', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + searchKey: 'abc', + }); + + await nextTick(); + const noMatchEl = findDropdownContent().find('li'); + + expect(noMatchEl.isVisible()).toBe(true); + expect(noMatchEl.text()).toContain('No matching results'); + }); + + it('renders empty content while loading', async () => { + wrapper.vm.$store.state.labelsFetchInProgress = true; + + await nextTick(); + const dropdownContent = findDropdownContent(); + const loadingIcon = findLoadingIcon(); + + expect(dropdownContent.exists()).toBe(true); + expect(dropdownContent.isVisible()).toBe(true); + expect(loadingIcon.exists()).toBe(true); + expect(loadingIcon.isVisible()).toBe(true); + }); + + it('renders footer list items', () => { + const footerLinks = findDropdownFooter().findAllComponents(GlLink); + const createLabelLink = footerLinks.at(0); + const manageLabelsLink = footerLinks.at(1); + + expect(createLabelLink.exists()).toBe(true); + expect(createLabelLink.text()).toBe('Create label'); + expect(manageLabelsLink.exists()).toBe(true); + expect(manageLabelsLink.text()).toBe('Manage labels'); + }); + + it('does not render "Create label" footer link when `state.allowLabelCreate` is `false`', async () => { + wrapper.vm.$store.state.allowLabelCreate = false; + + await nextTick(); + const createLabelLink = findDropdownFooter().findAllComponents(GlLink).at(0); + + expect(createLabelLink.text()).not.toBe('Create label'); + }); + + it('does not render footer list items when `state.variant` is "standalone"', () => { + createComponent({ ...mockConfig, variant: 'standalone' }); + expect(findDropdownFooter().exists()).toBe(false); + }); + + it('does not render footer list items when `allowLabelCreate` is false and `labelsManagePath` is null', () => { + createComponent({ + ...mockConfig, + allowLabelCreate: false, + labelsManagePath: null, + }); + expect(findDropdownFooter().exists()).toBe(false); + }); + + it('renders footer list items when `state.variant` is "embedded"', () => { + expect(findDropdownFooter().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_spec.js new file mode 100644 index 00000000000..e9ffda7c251 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_spec.js @@ -0,0 +1,68 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; + +import { DropdownVariant } from '~/sidebar/components/labels/labels_select_vue/constants'; +import DropdownContents from '~/sidebar/components/labels/labels_select_vue/dropdown_contents.vue'; +import labelsSelectModule from '~/sidebar/components/labels/labels_select_vue/store'; + +import { mockConfig } from './mock_data'; + +Vue.use(Vuex); + +const createComponent = (initialState = mockConfig, propsData = {}) => { + const store = new Vuex.Store(labelsSelectModule()); + + store.dispatch('setInitialState', initialState); + + return shallowMount(DropdownContents, { + propsData, + store, + }); +}; + +describe('DropdownContent', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('computed', () => { + describe('dropdownContentsView', () => { + it('returns string "dropdown-contents-create-view" when `showDropdownContentsCreateView` prop is `true`', () => { + wrapper.vm.$store.dispatch('toggleDropdownContentsCreateView'); + + expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-create-view'); + }); + + it('returns string "dropdown-contents-labels-view" when `showDropdownContentsCreateView` prop is `false`', () => { + expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-labels-view'); + }); + }); + }); + + describe('template', () => { + it('renders component container element with class `labels-select-dropdown-contents` and no styles', () => { + expect(wrapper.attributes('class')).toContain('labels-select-dropdown-contents'); + expect(wrapper.attributes('style')).toBeUndefined(); + }); + + describe('when `renderOnTop` is true', () => { + it.each` + variant | expected + ${DropdownVariant.Sidebar} | ${'bottom: 3rem'} + ${DropdownVariant.Standalone} | ${'bottom: 2rem'} + ${DropdownVariant.Embedded} | ${'bottom: 2rem'} + `('renders upward for $variant variant', ({ variant, expected }) => { + wrapper = createComponent({ ...mockConfig, variant }, { renderOnTop: true }); + + expect(wrapper.attributes('style')).toContain(expected); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_title_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_title_spec.js new file mode 100644 index 00000000000..6c3fda421ff --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_title_spec.js @@ -0,0 +1,59 @@ +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; + +import DropdownTitle from '~/sidebar/components/labels/labels_select_vue/dropdown_title.vue'; + +import labelsSelectModule from '~/sidebar/components/labels/labels_select_vue/store'; + +import { mockConfig } from './mock_data'; + +Vue.use(Vuex); + +const createComponent = (initialState = mockConfig) => { + const store = new Vuex.Store(labelsSelectModule()); + + store.dispatch('setInitialState', initialState); + + return shallowMount(DropdownTitle, { + store, + propsData: { + labelsSelectInProgress: false, + }, + }); +}; + +describe('DropdownTitle', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + it('renders component container element with string "Labels"', () => { + expect(wrapper.text()).toContain('Labels'); + }); + + it('renders edit link', () => { + const editBtnEl = wrapper.findComponent(GlButton); + + expect(editBtnEl.exists()).toBe(true); + expect(editBtnEl.text()).toBe('Edit'); + }); + + it('renders loading icon element when `labelsSelectInProgress` prop is true', async () => { + wrapper.setProps({ + labelsSelectInProgress: true, + }); + + await nextTick(); + expect(wrapper.findComponent(GlLoadingIcon).isVisible()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed_spec.js new file mode 100644 index 00000000000..56f25a1c6a4 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed_spec.js @@ -0,0 +1,75 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import DropdownValueCollapsedComponent from '~/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed.vue'; + +import { mockCollapsedLabels as mockLabels, mockRegularLabel } from './mock_data'; + +describe('DropdownValueCollapsedComponent', () => { + let wrapper; + + const defaultProps = { + labels: [], + }; + + const mockManyLabels = [...mockLabels, ...mockLabels, ...mockLabels]; + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(DropdownValueCollapsedComponent, { + propsData: { ...defaultProps, ...props }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findGlIcon = () => wrapper.findComponent(GlIcon); + const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip'); + + describe('template', () => { + it('renders tags icon element', () => { + createComponent(); + + expect(findGlIcon().exists()).toBe(true); + }); + + it('emits onValueClick event on click', async () => { + createComponent(); + + wrapper.trigger('click'); + + await nextTick(); + + expect(wrapper.emitted('onValueClick')[0]).toBeDefined(); + }); + + describe.each` + scenario | labels | expectedResult | expectedText + ${'`labels` is empty'} | ${[]} | ${'default text'} | ${'Labels'} + ${'`labels` has 1 item'} | ${[mockRegularLabel]} | ${'label name'} | ${'Foo Label'} + ${'`labels` has 2 items'} | ${mockLabels} | ${'comma separated label names'} | ${'Foo Label, Foo::Bar'} + ${'`labels` has more than 5 items'} | ${mockManyLabels} | ${'comma separated label names with "and more" phrase'} | ${'Foo Label, Foo::Bar, Foo Label, Foo::Bar, Foo Label, and 1 more'} + `('when $scenario', ({ labels, expectedResult, expectedText }) => { + beforeEach(() => { + createComponent({ + props: { + labels, + }, + }); + }); + + it('renders labels count', () => { + expect(wrapper.text()).toBe(`${labels.length}`); + }); + + it(`renders "${expectedResult}" as tooltip`, () => { + expect(getTooltip().value).toBe(expectedText); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_spec.js new file mode 100644 index 00000000000..a1ccc9d2ab1 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_spec.js @@ -0,0 +1,99 @@ +import { GlLabel } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; + +import DropdownValue from '~/sidebar/components/labels/labels_select_vue/dropdown_value.vue'; + +import labelsSelectModule from '~/sidebar/components/labels/labels_select_vue/store'; + +import { mockConfig, mockLabels, mockRegularLabel, mockScopedLabel } from './mock_data'; + +Vue.use(Vuex); + +describe('DropdownValue', () => { + let wrapper; + + const findAllLabels = () => wrapper.findAllComponents(GlLabel); + const findLabel = (index) => findAllLabels().at(index).props('title'); + + const createComponent = (initialState = {}, slots = {}) => { + const store = new Vuex.Store(labelsSelectModule()); + + store.dispatch('setInitialState', { ...mockConfig, ...initialState }); + + wrapper = shallowMount(DropdownValue, { + store, + slots, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('methods', () => { + describe('labelFilterUrl', () => { + it('returns a label filter URL based on provided label param', () => { + createComponent(); + + expect(wrapper.vm.labelFilterUrl(mockRegularLabel)).toBe( + '/gitlab-org/my-project/issues?label_name[]=Foo%20Label', + ); + }); + }); + + describe('scopedLabel', () => { + beforeEach(() => { + createComponent(); + }); + + it('returns `true` when provided label param is a scoped label', () => { + expect(wrapper.vm.scopedLabel(mockScopedLabel)).toBe(true); + }); + + it('returns `false` when provided label param is a regular label', () => { + expect(wrapper.vm.scopedLabel(mockRegularLabel)).toBe(false); + }); + }); + }); + + describe('template', () => { + it('renders class `has-labels` on component container element when `selectedLabels` is not empty', () => { + createComponent(); + + expect(wrapper.attributes('class')).toContain('has-labels'); + }); + + it('renders element containing `None` when `selectedLabels` is empty', () => { + createComponent( + { + selectedLabels: [], + }, + { + default: 'None', + }, + ); + const noneEl = wrapper.find('span.text-secondary'); + + expect(noneEl.exists()).toBe(true); + expect(noneEl.text()).toBe('None'); + }); + + it('renders labels when `selectedLabels` is not empty', () => { + createComponent(); + + expect(findAllLabels()).toHaveLength(2); + }); + + it('orders scoped labels first', () => { + createComponent({ selectedLabels: mockLabels }); + + expect(findAllLabels()).toHaveLength(mockLabels.length); + expect(findLabel(0)).toBe('Foo::Bar'); + expect(findLabel(1)).toBe('Boog'); + expect(findLabel(2)).toBe('Bug'); + expect(findLabel(3)).toBe('Foo Label'); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/label_item_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/label_item_spec.js new file mode 100644 index 00000000000..e14c0e308ce --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/label_item_spec.js @@ -0,0 +1,92 @@ +import { GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; + +import LabelItem from '~/sidebar/components/labels/labels_select_vue/label_item.vue'; +import { mockRegularLabel } from './mock_data'; + +const mockLabel = { ...mockRegularLabel, set: true }; + +const createComponent = ({ + label = mockLabel, + isLabelSet = mockLabel.set, + highlight = true, +} = {}) => + shallowMount(LabelItem, { + propsData: { + label, + isLabelSet, + highlight, + }, + }); + +describe('LabelItem', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + it('renders gl-link component', () => { + expect(wrapper.findComponent(GlLink).exists()).toBe(true); + }); + + it('renders component root with class `is-focused` when `highlight` prop is true', () => { + const wrapperTemp = createComponent({ + highlight: true, + }); + + expect(wrapperTemp.classes()).toContain('is-focused'); + + wrapperTemp.destroy(); + }); + + it.each` + isLabelSet | isLabelIndeterminate | testId | iconName + ${true} | ${false} | ${'checked-icon'} | ${'mobile-issue-close'} + ${false} | ${true} | ${'indeterminate-icon'} | ${'dash'} + `( + 'renders visible gl-icon component when `isLabelSet` prop is $isLabelSet and `isLabelIndeterminate` is $isLabelIndeterminate', + ({ isLabelSet, isLabelIndeterminate, testId, iconName }) => { + const wrapperTemp = createComponent({ + isLabelSet, + isLabelIndeterminate, + }); + + const iconEl = wrapperTemp.find(`[data-testid="${testId}"]`); + + expect(iconEl.isVisible()).toBe(true); + expect(iconEl.props('name')).toBe(iconName); + + wrapperTemp.destroy(); + }, + ); + + it('renders visible span element as placeholder instead of gl-icon when `isLabelSet` prop is false', () => { + const wrapperTemp = createComponent({ + isLabelSet: false, + }); + + const placeholderEl = wrapperTemp.find('[data-testid="no-icon"]'); + + expect(placeholderEl.isVisible()).toBe(true); + + wrapperTemp.destroy(); + }); + + it('renders label color element', () => { + const colorEl = wrapper.find('[data-testid="label-color-box"]'); + + expect(colorEl.exists()).toBe(true); + expect(colorEl.attributes('style')).toBe('background-color: rgb(186, 218, 85);'); + }); + + it('renders label title', () => { + expect(wrapper.text()).toContain(mockLabel.title); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/labels_select_root_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/labels_select_root_spec.js new file mode 100644 index 00000000000..a3b10c18374 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/labels_select_root_spec.js @@ -0,0 +1,231 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; + +import { isInViewport } from '~/lib/utils/common_utils'; +import { DropdownVariant } from '~/sidebar/components/labels/labels_select_vue/constants'; +import DropdownButton from '~/sidebar/components/labels/labels_select_vue/dropdown_button.vue'; +import DropdownContents from '~/sidebar/components/labels/labels_select_vue/dropdown_contents.vue'; +import DropdownTitle from '~/sidebar/components/labels/labels_select_vue/dropdown_title.vue'; +import DropdownValue from '~/sidebar/components/labels/labels_select_vue/dropdown_value.vue'; +import DropdownValueCollapsed from '~/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed.vue'; +import LabelsSelectRoot from '~/sidebar/components/labels/labels_select_vue/labels_select_root.vue'; + +import labelsSelectModule from '~/sidebar/components/labels/labels_select_vue/store'; + +import { mockConfig } from './mock_data'; + +jest.mock('~/lib/utils/common_utils', () => ({ + isInViewport: jest.fn().mockReturnValue(true), +})); + +Vue.use(Vuex); + +describe('LabelsSelectRoot', () => { + let wrapper; + let store; + + const createComponent = (config = mockConfig, slots = {}) => { + wrapper = shallowMount(LabelsSelectRoot, { + slots, + store, + propsData: config, + stubs: { + 'dropdown-contents': DropdownContents, + }, + }); + }; + + beforeEach(() => { + store = new Vuex.Store(labelsSelectModule()); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('methods', () => { + describe('handleVuexActionDispatch', () => { + const touchedLabels = [ + { + id: 2, + touched: true, + }, + ]; + + it('calls `handleDropdownClose` when params `action.type` is `toggleDropdownContents` and state has `showDropdownButton` & `showDropdownContents` props `false`', () => { + createComponent(); + + wrapper.vm.handleVuexActionDispatch( + { type: 'toggleDropdownContents' }, + { + showDropdownButton: false, + showDropdownContents: false, + labels: [{ id: 1 }, { id: 2, touched: true }], + }, + ); + + // We're utilizing `onDropdownClose` event emitted from the component to always include `touchedLabels` + // while the first param of the method is the labels list which were added/removed. + expect(wrapper.emitted('updateSelectedLabels')).toHaveLength(1); + expect(wrapper.emitted('updateSelectedLabels')[0]).toEqual([touchedLabels]); + expect(wrapper.emitted('onDropdownClose')).toHaveLength(1); + expect(wrapper.emitted('onDropdownClose')[0]).toEqual([touchedLabels]); + }); + + it('calls `handleDropdownClose` with state.labels filterd using `set` prop when dropdown variant is `embedded`', () => { + createComponent({ + ...mockConfig, + variant: 'embedded', + }); + + wrapper.vm.handleVuexActionDispatch( + { type: 'toggleDropdownContents' }, + { + showDropdownButton: false, + showDropdownContents: false, + labels: [{ id: 1 }, { id: 2, set: true }], + }, + ); + + expect(wrapper.emitted('updateSelectedLabels')).toHaveLength(1); + expect(wrapper.emitted('updateSelectedLabels')[0]).toEqual([ + [ + { + id: 2, + set: true, + }, + ], + ]); + expect(wrapper.emitted('onDropdownClose')).toHaveLength(1); + expect(wrapper.emitted('onDropdownClose')[0]).toEqual([[]]); + }); + }); + + describe('handleCollapsedValueClick', () => { + it('emits `toggleCollapse` event on component', () => { + createComponent(); + wrapper.vm.handleCollapsedValueClick(); + expect(wrapper.emitted().toggleCollapse).toHaveLength(1); + }); + }); + }); + + describe('template', () => { + it('renders component with classes `labels-select-wrapper position-relative`', () => { + createComponent(); + expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative'); + }); + + it.each` + variant | cssClass + ${'standalone'} | ${'is-standalone'} + ${'embedded'} | ${'is-embedded'} + `( + 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"', + async ({ variant, cssClass }) => { + createComponent({ + ...mockConfig, + variant, + }); + + await nextTick(); + expect(wrapper.classes()).toContain(cssClass); + }, + ); + + it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', async () => { + createComponent(); + await nextTick(); + expect(wrapper.findComponent(DropdownValueCollapsed).exists()).toBe(true); + }); + + it('renders `dropdown-title` component', async () => { + createComponent(); + await nextTick(); + expect(wrapper.findComponent(DropdownTitle).exists()).toBe(true); + }); + + it('renders `dropdown-value` component', async () => { + createComponent(mockConfig, { + default: 'None', + }); + await nextTick(); + + const valueComp = wrapper.findComponent(DropdownValue); + + expect(valueComp.exists()).toBe(true); + expect(valueComp.text()).toBe('None'); + }); + + it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', async () => { + createComponent(); + wrapper.vm.$store.dispatch('toggleDropdownButton'); + await nextTick(); + expect(wrapper.findComponent(DropdownButton).exists()).toBe(true); + }); + + it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', async () => { + createComponent(); + wrapper.vm.$store.dispatch('toggleDropdownContents'); + await nextTick(); + expect(wrapper.findComponent(DropdownContents).exists()).toBe(true); + }); + + describe('sets content direction based on viewport', () => { + describe.each(Object.values(DropdownVariant))( + 'when labels variant is "%s"', + ({ variant }) => { + beforeEach(() => { + createComponent({ ...mockConfig, variant }); + wrapper.vm.$store.dispatch('toggleDropdownContents'); + }); + + it('set direction when out of viewport', async () => { + isInViewport.mockImplementation(() => false); + wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state); + + await nextTick(); + expect(wrapper.findComponent(DropdownContents).props('renderOnTop')).toBe(true); + }); + + it('does not set direction when inside of viewport', async () => { + isInViewport.mockImplementation(() => true); + wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state); + + await nextTick(); + expect(wrapper.findComponent(DropdownContents).props('renderOnTop')).toBe(false); + }); + }, + ); + }); + }); + + it('calls toggleDropdownContents action when isEditing prop is changing to true', async () => { + createComponent(); + + jest.spyOn(store, 'dispatch').mockResolvedValue(); + await wrapper.setProps({ isEditing: true }); + + expect(store.dispatch).toHaveBeenCalledWith('toggleDropdownContents'); + }); + + it('does not call toggleDropdownContents action when isEditing prop is changing to false', async () => { + createComponent(); + + jest.spyOn(store, 'dispatch').mockResolvedValue(); + await wrapper.setProps({ isEditing: false }); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('calls updateLabelsSetState after selected labels were updated', async () => { + createComponent(); + + jest.spyOn(store, 'dispatch').mockResolvedValue(); + await wrapper.setProps({ selectedLabels: [] }); + jest.advanceTimersByTime(100); + + expect(store.dispatch).toHaveBeenCalledWith('updateLabelsSetState'); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/mock_data.js b/spec/frontend/sidebar/components/labels/labels_select_vue/mock_data.js new file mode 100644 index 00000000000..884bc4684ba --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/mock_data.js @@ -0,0 +1,92 @@ +export const mockRegularLabel = { + id: 26, + title: 'Foo Label', + description: 'Foobar', + color: '#BADA55', + textColor: '#FFFFFF', +}; + +export const mockScopedLabel = { + id: 27, + title: 'Foo::Bar', + description: 'Foobar', + color: '#0033CC', + textColor: '#FFFFFF', +}; + +export const mockLabels = [ + { + id: 29, + title: 'Boog', + description: 'Label for bugs', + color: '#FF0000', + textColor: '#FFFFFF', + }, + { + id: 28, + title: 'Bug', + description: 'Label for bugs', + color: '#FF0000', + textColor: '#FFFFFF', + }, + mockRegularLabel, + mockScopedLabel, +]; + +export const mockCollapsedLabels = [ + { + id: 26, + title: 'Foo Label', + description: 'Foobar', + color: '#BADA55', + text_color: '#FFFFFF', + }, + { + id: 27, + title: 'Foo::Bar', + description: 'Foobar', + color: '#0033CC', + text_color: '#FFFFFF', + }, +]; + +export const mockConfig = { + allowLabelEdit: true, + allowLabelCreate: true, + allowScopedLabels: true, + allowMultiselect: true, + labelsListTitle: 'Assign labels', + labelsCreateTitle: 'Create label', + variant: 'sidebar', + dropdownOnly: false, + selectedLabels: [mockRegularLabel, mockScopedLabel], + labelsSelectInProgress: false, + labelsFetchPath: '/gitlab-org/my-project/-/labels.json', + labelsManagePath: '/gitlab-org/my-project/-/labels', + labelsFilterBasePath: '/gitlab-org/my-project/issues', + labelsFilterParam: 'label_name', +}; + +export const mockSuggestedColors = { + '#009966': 'Green-cyan', + '#8fbc8f': 'Dark sea green', + '#3cb371': 'Medium sea green', + '#00b140': 'Green screen', + '#013220': 'Dark green', + '#6699cc': 'Blue-gray', + '#0000ff': 'Blue', + '#e6e6fa': 'Lavender', + '#9400d3': 'Dark violet', + '#330066': 'Deep violet', + '#808080': 'Gray', + '#36454f': 'Charcoal grey', + '#f7e7ce': 'Champagne', + '#c21e56': 'Rose red', + '#cc338b': 'Magenta-pink', + '#dc143c': 'Crimson', + '#ff0000': 'Red', + '#cd5b45': 'Dark coral', + '#eee600': 'Titanium yellow', + '#ed9121': 'Carrot orange', + '#c39953': 'Aztec Gold', +}; diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js new file mode 100644 index 00000000000..0e0024aa6c2 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js @@ -0,0 +1,265 @@ +import MockAdapter from 'axios-mock-adapter'; + +import testAction from 'helpers/vuex_action_helper'; +import { createAlert } from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import * as actions from '~/sidebar/components/labels/labels_select_vue/store/actions'; +import * as types from '~/sidebar/components/labels/labels_select_vue/store/mutation_types'; +import defaultState from '~/sidebar/components/labels/labels_select_vue/store/state'; + +jest.mock('~/flash'); + +describe('LabelsSelect Actions', () => { + let state; + const mockInitialState = { + labels: [], + selectedLabels: [], + }; + + beforeEach(() => { + state = { ...defaultState() }; + }); + + describe('setInitialState', () => { + it('sets initial store state', () => { + return testAction( + actions.setInitialState, + mockInitialState, + state, + [{ type: types.SET_INITIAL_STATE, payload: mockInitialState }], + [], + ); + }); + }); + + describe('toggleDropdownButton', () => { + it('toggles dropdown button', () => { + return testAction( + actions.toggleDropdownButton, + {}, + state, + [{ type: types.TOGGLE_DROPDOWN_BUTTON }], + [], + ); + }); + }); + + describe('toggleDropdownContents', () => { + it('toggles dropdown contents', () => { + return testAction( + actions.toggleDropdownContents, + {}, + state, + [{ type: types.TOGGLE_DROPDOWN_CONTENTS }], + [], + ); + }); + }); + + describe('toggleDropdownContentsCreateView', () => { + it('toggles dropdown create view', () => { + return testAction( + actions.toggleDropdownContentsCreateView, + {}, + state, + [{ type: types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW }], + [], + ); + }); + }); + + describe('requestLabels', () => { + it('sets value of `state.labelsFetchInProgress` to `true`', () => { + return testAction(actions.requestLabels, {}, state, [{ type: types.REQUEST_LABELS }], []); + }); + }); + + describe('receiveLabelsSuccess', () => { + it('sets provided labels to `state.labels`', () => { + const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + + return testAction( + actions.receiveLabelsSuccess, + labels, + state, + [{ type: types.RECEIVE_SET_LABELS_SUCCESS, payload: labels }], + [], + ); + }); + }); + + describe('receiveLabelsFailure', () => { + it('sets value `state.labelsFetchInProgress` to `false`', () => { + return testAction( + actions.receiveLabelsFailure, + {}, + state, + [{ type: types.RECEIVE_SET_LABELS_FAILURE }], + [], + ); + }); + + it('shows flash error', () => { + actions.receiveLabelsFailure({ commit: () => {} }); + + expect(createAlert).toHaveBeenCalledWith({ message: 'Error fetching labels.' }); + }); + }); + + describe('fetchLabels', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + state.labelsFetchPath = 'labels.json'; + }); + + afterEach(() => { + mock.restore(); + }); + + describe('on success', () => { + it('dispatches `requestLabels` & `receiveLabelsSuccess` actions', () => { + const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + mock.onGet(/labels.json/).replyOnce(200, labels); + + return testAction( + actions.fetchLabels, + {}, + state, + [], + [{ type: 'requestLabels' }, { type: 'receiveLabelsSuccess', payload: labels }], + ); + }); + }); + + describe('on failure', () => { + it('dispatches `requestLabels` & `receiveLabelsFailure` actions', () => { + mock.onGet(/labels.json/).replyOnce(500, {}); + + return testAction( + actions.fetchLabels, + {}, + state, + [], + [{ type: 'requestLabels' }, { type: 'receiveLabelsFailure' }], + ); + }); + }); + }); + + describe('requestCreateLabel', () => { + it('sets value `state.labelCreateInProgress` to `true`', () => { + return testAction( + actions.requestCreateLabel, + {}, + state, + [{ type: types.REQUEST_CREATE_LABEL }], + [], + ); + }); + }); + + describe('receiveCreateLabelSuccess', () => { + it('sets value `state.labelCreateInProgress` to `false`', () => { + return testAction( + actions.receiveCreateLabelSuccess, + {}, + state, + [{ type: types.RECEIVE_CREATE_LABEL_SUCCESS }], + [], + ); + }); + }); + + describe('receiveCreateLabelFailure', () => { + it('sets value `state.labelCreateInProgress` to `false`', () => { + return testAction( + actions.receiveCreateLabelFailure, + {}, + state, + [{ type: types.RECEIVE_CREATE_LABEL_FAILURE }], + [], + ); + }); + + it('shows flash error', () => { + actions.receiveCreateLabelFailure({ commit: () => {} }); + + expect(createAlert).toHaveBeenCalledWith({ message: 'Error creating label.' }); + }); + }); + + describe('createLabel', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + state.labelsManagePath = 'labels.json'; + }); + + afterEach(() => { + mock.restore(); + }); + + describe('on success', () => { + it('dispatches `requestCreateLabel`, `fetchLabels` & `receiveCreateLabelSuccess` & `toggleDropdownContentsCreateView` actions', () => { + const label = { id: 1 }; + mock.onPost(/labels.json/).replyOnce(200, label); + + return testAction( + actions.createLabel, + {}, + state, + [], + [ + { type: 'requestCreateLabel' }, + { payload: { refetch: true }, type: 'fetchLabels' }, + { type: 'receiveCreateLabelSuccess' }, + { type: 'toggleDropdownContentsCreateView' }, + ], + ); + }); + }); + + describe('on failure', () => { + it('dispatches `requestCreateLabel` & `receiveCreateLabelFailure` actions', () => { + mock.onPost(/labels.json/).replyOnce(500, {}); + + return testAction( + actions.createLabel, + {}, + state, + [], + [{ type: 'requestCreateLabel' }, { type: 'receiveCreateLabelFailure' }], + ); + }); + }); + }); + + describe('updateSelectedLabels', () => { + it('updates `state.labels` based on provided `labels` param', () => { + const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + + return testAction( + actions.updateSelectedLabels, + labels, + state, + [{ type: types.UPDATE_SELECTED_LABELS, payload: { labels } }], + [], + ); + }); + }); + + describe('updateLabelsSetState', () => { + it('updates labels `set` state to match `selectedLabels`', () => { + testAction( + actions.updateLabelsSetState, + {}, + state, + [{ type: types.UPDATE_LABELS_SET_STATE }], + [], + ); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/store/getters_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/store/getters_spec.js new file mode 100644 index 00000000000..e32256831a3 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/store/getters_spec.js @@ -0,0 +1,74 @@ +import * as getters from '~/sidebar/components/labels/labels_select_vue/store/getters'; + +describe('LabelsSelect Getters', () => { + describe('dropdownButtonText', () => { + it.each` + labelType | dropdownButtonText | expected + ${'default'} | ${''} | ${'Label'} + ${'custom'} | ${'Custom label'} | ${'Custom label'} + `( + 'returns $labelType text when state.labels has no selected labels', + ({ dropdownButtonText, expected }) => { + const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + const selectedLabels = []; + const state = { labels, selectedLabels, dropdownButtonText }; + + expect(getters.dropdownButtonText(state, {})).toBe(expected); + }, + ); + + describe.each` + dropdownVariant | isDropdownVariantSidebar | isDropdownVariantEmbedded + ${'sidebar'} | ${true} | ${false} + ${'embedded'} | ${false} | ${true} + `( + 'when dropdown variant is $dropdownVariant', + ({ isDropdownVariantSidebar, isDropdownVariantEmbedded }) => { + it('returns label title when state.labels has only 1 label', () => { + const labels = [{ id: 1, title: 'Foobar', set: true }]; + + expect( + getters.dropdownButtonText( + { labels }, + { isDropdownVariantSidebar, isDropdownVariantEmbedded }, + ), + ).toBe('Foobar'); + }); + + it('returns first label title and remaining labels count when state.labels has more than 1 label', () => { + const labels = [ + { id: 1, title: 'Foo', set: true }, + { id: 2, title: 'Bar', set: true }, + ]; + + expect( + getters.dropdownButtonText( + { labels }, + { isDropdownVariantSidebar, isDropdownVariantEmbedded }, + ), + ).toBe('Foo +1 more'); + }); + }, + ); + }); + + describe('selectedLabelsList', () => { + it('returns array of IDs of all labels within `state.selectedLabels`', () => { + const selectedLabels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + + expect(getters.selectedLabelsList({ selectedLabels })).toEqual([1, 2, 3, 4]); + }); + }); + + describe('isDropdownVariantSidebar', () => { + it('returns `true` when `state.variant` is "sidebar"', () => { + expect(getters.isDropdownVariantSidebar({ variant: 'sidebar' })).toBe(true); + }); + }); + + describe('isDropdownVariantStandalone', () => { + it('returns `true` when `state.variant` is "standalone"', () => { + expect(getters.isDropdownVariantStandalone({ variant: 'standalone' })).toBe(true); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/store/mutations_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/store/mutations_spec.js new file mode 100644 index 00000000000..cee5d2e77d1 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/store/mutations_spec.js @@ -0,0 +1,232 @@ +import { cloneDeep } from 'lodash'; +import * as types from '~/sidebar/components/labels/labels_select_vue/store/mutation_types'; +import mutations from '~/sidebar/components/labels/labels_select_vue/store/mutations'; + +describe('LabelsSelect Mutations', () => { + describe(`${types.SET_INITIAL_STATE}`, () => { + it('initializes provided props to store state', () => { + const state = {}; + mutations[types.SET_INITIAL_STATE](state, { + labels: 'foo', + }); + + expect(state.labels).toEqual('foo'); + }); + }); + + describe(`${types.TOGGLE_DROPDOWN_BUTTON}`, () => { + it('toggles value of `state.showDropdownButton`', () => { + const state = { + showDropdownButton: false, + }; + mutations[types.TOGGLE_DROPDOWN_BUTTON](state); + + expect(state.showDropdownButton).toBe(true); + }); + }); + + describe(`${types.TOGGLE_DROPDOWN_CONTENTS}`, () => { + it('toggles value of `state.showDropdownButton` when `state.dropdownOnly` is false', () => { + const state = { + dropdownOnly: false, + showDropdownButton: false, + variant: 'sidebar', + }; + mutations[types.TOGGLE_DROPDOWN_CONTENTS](state); + + expect(state.showDropdownButton).toBe(true); + }); + + it('toggles value of `state.showDropdownContents`', () => { + const state = { + showDropdownContents: false, + }; + mutations[types.TOGGLE_DROPDOWN_CONTENTS](state); + + expect(state.showDropdownContents).toBe(true); + }); + + it('sets value of `state.showDropdownContentsCreateView` to `false` when `showDropdownContents` is true', () => { + const state = { + showDropdownContents: false, + showDropdownContentsCreateView: true, + }; + mutations[types.TOGGLE_DROPDOWN_CONTENTS](state); + + expect(state.showDropdownContentsCreateView).toBe(false); + }); + }); + + describe(`${types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW}`, () => { + it('toggles value of `state.showDropdownContentsCreateView`', () => { + const state = { + showDropdownContentsCreateView: false, + }; + mutations[types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state); + + expect(state.showDropdownContentsCreateView).toBe(true); + }); + }); + + describe(`${types.REQUEST_LABELS}`, () => { + it('sets value of `state.labelsFetchInProgress` to true', () => { + const state = { + labelsFetchInProgress: false, + }; + mutations[types.REQUEST_LABELS](state); + + expect(state.labelsFetchInProgress).toBe(true); + }); + }); + + describe(`${types.RECEIVE_SET_LABELS_SUCCESS}`, () => { + const selectedLabels = [ + { id: 2, set: true }, + { id: 4, set: true }, + ]; + const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + + it('sets value of `state.labelsFetchInProgress` to false', () => { + const state = { + selectedLabels, + labelsFetchInProgress: true, + }; + mutations[types.RECEIVE_SET_LABELS_SUCCESS](state, labels); + + expect(state.labelsFetchInProgress).toBe(false); + }); + + it('sets provided `labels` to `state.labels` along with `set` prop based on `state.selectedLabels`', () => { + const selectedLabelIds = selectedLabels.map((label) => label.id); + const state = { + selectedLabels, + labelsFetchInProgress: true, + }; + mutations[types.RECEIVE_SET_LABELS_SUCCESS](state, labels); + + state.labels.forEach((label) => { + if (selectedLabelIds.includes(label.id)) { + expect(label.set).toBe(true); + } + }); + }); + }); + + describe(`${types.RECEIVE_SET_LABELS_FAILURE}`, () => { + it('sets value of `state.labelsFetchInProgress` to false', () => { + const state = { + labelsFetchInProgress: true, + }; + mutations[types.RECEIVE_SET_LABELS_FAILURE](state); + + expect(state.labelsFetchInProgress).toBe(false); + }); + }); + + describe(`${types.REQUEST_CREATE_LABEL}`, () => { + it('sets value of `state.labelCreateInProgress` to true', () => { + const state = { + labelCreateInProgress: false, + }; + mutations[types.REQUEST_CREATE_LABEL](state); + + expect(state.labelCreateInProgress).toBe(true); + }); + }); + + describe(`${types.RECEIVE_CREATE_LABEL_SUCCESS}`, () => { + it('sets value of `state.labelCreateInProgress` to false', () => { + const state = { + labelCreateInProgress: false, + }; + mutations[types.RECEIVE_CREATE_LABEL_SUCCESS](state); + + expect(state.labelCreateInProgress).toBe(false); + }); + }); + + describe(`${types.RECEIVE_CREATE_LABEL_FAILURE}`, () => { + it('sets value of `state.labelCreateInProgress` to false', () => { + const state = { + labelCreateInProgress: false, + }; + mutations[types.RECEIVE_CREATE_LABEL_FAILURE](state); + + expect(state.labelCreateInProgress).toBe(false); + }); + }); + + describe(`${types.UPDATE_SELECTED_LABELS}`, () => { + const labels = [ + { id: 1, title: 'scoped' }, + { id: 2, title: 'scoped::label::one', set: false }, + { id: 3, title: 'scoped::label::two', set: false }, + { id: 4, title: 'scoped::label::three', set: true }, + { id: 5, title: 'scoped::one', set: false }, + { id: 6, title: 'scoped::two', set: false }, + { id: 7, title: 'scoped::three', set: true }, + { id: 8, title: '' }, + ]; + + it.each` + label | labelGroupIds + ${labels[0]} | ${[]} + ${labels[1]} | ${[labels[2], labels[3]]} + ${labels[2]} | ${[labels[1], labels[3]]} + ${labels[3]} | ${[labels[1], labels[2]]} + ${labels[4]} | ${[labels[5], labels[6]]} + ${labels[5]} | ${[labels[4], labels[6]]} + ${labels[6]} | ${[labels[4], labels[5]]} + ${labels[7]} | ${[]} + `('updates `touched` and `set` props for $label.title', ({ label, labelGroupIds }) => { + const state = { labels: cloneDeep(labels) }; + + mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: label.id }] }); + + expect(state.labels[label.id - 1]).toMatchObject({ + touched: true, + set: !labels[label.id - 1].set, + }); + + labelGroupIds.forEach((l) => { + expect(state.labels[l.id - 1].touched).toBeUndefined(); + expect(state.labels[l.id - 1].set).toBe(false); + }); + }); + it('allows selection of multiple scoped labels', () => { + const state = { labels: cloneDeep(labels), allowMultipleScopedLabels: true }; + + mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: labels[4].id }] }); + mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: labels[5].id }] }); + + expect(state.labels[4].set).toBe(true); + expect(state.labels[5].set).toBe(true); + expect(state.labels[6].set).toBe(true); + }); + }); + + describe(`${types.UPDATE_LABELS_SET_STATE}`, () => { + it('updates labels `set` state to match selected labels', () => { + const state = { + labels: [ + { id: 1, title: 'scoped::test', set: false, indeterminate: false }, + { id: 2, title: 'scoped::one', set: true, indeterminate: false, touched: true }, + { id: 3, title: '', set: false, indeterminate: false }, + { id: 4, title: '', set: false, indeterminate: false }, + ], + selectedLabels: [ + { id: 1, set: true }, + { id: 3, set: true }, + ], + }; + mutations[types.UPDATE_LABELS_SET_STATE](state); + + expect(state.labels).toEqual([ + { id: 1, title: 'scoped::test', set: true, indeterminate: false }, + { id: 2, title: 'scoped::one', set: false, indeterminate: false, touched: true }, + { id: 3, title: '', set: true, indeterminate: false }, + { id: 4, title: '', set: false, indeterminate: false }, + ]); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js new file mode 100644 index 00000000000..79b164b0ea7 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js @@ -0,0 +1,239 @@ +import { GlAlert, GlLoadingIcon, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/flash'; +import { workspaceLabelsQueries } from '~/sidebar/constants'; +import DropdownContentsCreateView from '~/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue'; +import createLabelMutation from '~/sidebar/components/labels/labels_select_widget/graphql/create_label.mutation.graphql'; +import { + mockRegularLabel, + mockSuggestedColors, + createLabelSuccessfulResponse, + workspaceLabelsQueryResponse, +} from './mock_data'; + +jest.mock('~/flash'); + +const colors = Object.keys(mockSuggestedColors); + +Vue.use(VueApollo); + +const userRecoverableError = { + ...createLabelSuccessfulResponse, + errors: ['Houston, we have a problem'], +}; + +const titleTakenError = { + data: { + labelCreate: { + label: mockRegularLabel, + errors: ['Title has already been taken'], + }, + }, +}; + +const createLabelSuccessHandler = jest.fn().mockResolvedValue(createLabelSuccessfulResponse); +const createLabelUserRecoverableErrorHandler = jest.fn().mockResolvedValue(userRecoverableError); +const createLabelDuplicateErrorHandler = jest.fn().mockResolvedValue(titleTakenError); +const createLabelErrorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); + +describe('DropdownContentsCreateView', () => { + let wrapper; + + const findAllColors = () => wrapper.findAllComponents(GlLink); + const findSelectedColor = () => wrapper.find('[data-testid="selected-color"]'); + const findSelectedColorText = () => wrapper.find('[data-testid="selected-color-text"]'); + const findCreateButton = () => wrapper.find('[data-testid="create-button"]'); + const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]'); + const findLabelTitleInput = () => wrapper.find('[data-testid="label-title-input"]'); + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + const fillLabelAttributes = () => { + findLabelTitleInput().vm.$emit('input', 'Test title'); + findAllColors().at(0).vm.$emit('click', new Event('mouseclick')); + }; + + const createComponent = ({ + mutationHandler = createLabelSuccessHandler, + labelCreateType = 'project', + workspaceType = 'project', + } = {}) => { + const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]); + mockApollo.clients.defaultClient.cache.writeQuery({ + query: workspaceLabelsQueries[workspaceType].query, + data: workspaceLabelsQueryResponse.data, + variables: { + fullPath: '', + searchTerm: '', + }, + }); + + wrapper = shallowMount(DropdownContentsCreateView, { + apolloProvider: mockApollo, + propsData: { + fullPath: '', + attrWorkspacePath: '', + labelCreateType, + workspaceType, + }, + }); + }; + + beforeEach(() => { + gon.suggested_label_colors = mockSuggestedColors; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a palette of 21 colors', () => { + createComponent(); + expect(findAllColors()).toHaveLength(21); + }); + + it('selects a color after clicking on colored block', async () => { + createComponent(); + expect(findSelectedColor().attributes('style')).toBeUndefined(); + + findAllColors().at(0).vm.$emit('click', new Event('mouseclick')); + await nextTick(); + + expect(findSelectedColor().attributes('style')).toBe('background-color: rgb(0, 153, 102);'); + }); + + it('shows correct color hex code after selecting a color', async () => { + createComponent(); + expect(findSelectedColorText().attributes('value')).toBe(''); + + findAllColors().at(0).vm.$emit('click', new Event('mouseclick')); + await nextTick(); + + expect(findSelectedColorText().attributes('value')).toBe(colors[0]); + }); + + it('disables a Create button if label title is not set', async () => { + createComponent(); + findAllColors().at(0).vm.$emit('click', new Event('mouseclick')); + await nextTick(); + + expect(findCreateButton().props('disabled')).toBe(true); + }); + + it('disables a Create button if color is not set', async () => { + createComponent(); + findLabelTitleInput().vm.$emit('input', 'Test title'); + await nextTick(); + + expect(findCreateButton().props('disabled')).toBe(true); + }); + + it('does not render a loader spinner', () => { + createComponent(); + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('emits a `hideCreateView` event on Cancel button click', () => { + createComponent(); + const event = { stopPropagation: jest.fn() }; + findCancelButton().vm.$emit('click', event); + + expect(wrapper.emitted('hideCreateView')).toHaveLength(1); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + describe('when label title and selected color are set', () => { + beforeEach(() => { + createComponent(); + fillLabelAttributes(); + }); + + it('enables a Create button', () => { + expect(findCreateButton().props()).toMatchObject({ + disabled: false, + category: 'primary', + variant: 'confirm', + }); + }); + + it('renders a loader spinner after Create button click', async () => { + findCreateButton().vm.$emit('click'); + await nextTick(); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not loader spinner after mutation is resolved', async () => { + findCreateButton().vm.$emit('click'); + await nextTick(); + + expect(findLoadingIcon().exists()).toBe(true); + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + it('calls a mutation with `projectPath` variable on the issue', () => { + createComponent(); + fillLabelAttributes(); + findCreateButton().vm.$emit('click'); + + expect(createLabelSuccessHandler).toHaveBeenCalledWith({ + color: '#009966', + projectPath: '', + title: 'Test title', + }); + }); + + it('calls a mutation with `groupPath` variable on the epic', () => { + createComponent({ labelCreateType: 'group', workspaceType: 'group' }); + fillLabelAttributes(); + findCreateButton().vm.$emit('click'); + + expect(createLabelSuccessHandler).toHaveBeenCalledWith({ + color: '#009966', + groupPath: '', + title: 'Test title', + }); + }); + + it('calls createAlert is mutation has a user-recoverable error', async () => { + createComponent({ mutationHandler: createLabelUserRecoverableErrorHandler }); + fillLabelAttributes(); + await nextTick(); + + findCreateButton().vm.$emit('click'); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalled(); + }); + + it('calls createAlert is mutation was rejected', async () => { + createComponent({ mutationHandler: createLabelErrorHandler }); + fillLabelAttributes(); + await nextTick(); + + findCreateButton().vm.$emit('click'); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalled(); + }); + + it('displays error in alert if label title is already taken', async () => { + createComponent({ mutationHandler: createLabelDuplicateErrorHandler }); + fillLabelAttributes(); + await nextTick(); + + findCreateButton().vm.$emit('click'); + await waitForPromises(); + + expect(wrapper.findComponent(GlAlert).text()).toEqual( + titleTakenError.data.labelCreate.errors[0], + ); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view_spec.js new file mode 100644 index 00000000000..913badccbe4 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view_spec.js @@ -0,0 +1,170 @@ +import { + GlLoadingIcon, + GlSearchBoxByType, + GlDropdownItem, + GlIntersectionObserver, +} from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/flash'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { DropdownVariant } from '~/sidebar/components/labels/labels_select_widget/constants'; +import DropdownContentsLabelsView from '~/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue'; +import projectLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql'; +import LabelItem from '~/sidebar/components/labels/labels_select_widget/label_item.vue'; +import { mockConfig, workspaceLabelsQueryResponse } from './mock_data'; + +jest.mock('~/flash'); + +Vue.use(VueApollo); + +const localSelectedLabels = [ + { + color: '#2f7b2e', + description: null, + id: 'gid://gitlab/ProjectLabel/2', + title: 'Label2', + }, +]; + +describe('DropdownContentsLabelsView', () => { + let wrapper; + + const successfulQueryHandler = jest.fn().mockResolvedValue(workspaceLabelsQueryResponse); + + const findFirstLabel = () => wrapper.findAllComponents(GlDropdownItem).at(0); + + const createComponent = ({ + initialState = mockConfig, + queryHandler = successfulQueryHandler, + injected = {}, + searchKey = '', + } = {}) => { + const mockApollo = createMockApollo([[projectLabelsQuery, queryHandler]]); + + wrapper = shallowMount(DropdownContentsLabelsView, { + apolloProvider: mockApollo, + provide: { + variant: DropdownVariant.Sidebar, + ...injected, + }, + propsData: { + ...initialState, + localSelectedLabels, + searchKey, + labelCreateType: 'project', + workspaceType: 'project', + }, + stubs: { + GlSearchBoxByType, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findLabels = () => wrapper.findAllComponents(LabelItem); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findObserver = () => wrapper.findComponent(GlIntersectionObserver); + + const findLabelsList = () => wrapper.find('[data-testid="labels-list"]'); + const findNoResultsMessage = () => wrapper.find('[data-testid="no-results"]'); + + async function makeObserverAppear() { + await findObserver().vm.$emit('appear'); + } + + describe('when loading labels', () => { + it('renders loading icon', async () => { + createComponent(); + await makeObserverAppear(); + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not render labels list', async () => { + createComponent(); + await makeObserverAppear(); + expect(findLabelsList().exists()).toBe(false); + }); + }); + + describe('when labels are loaded', () => { + beforeEach(async () => { + createComponent(); + await makeObserverAppear(); + await waitForPromises(); + }); + + it('does not render loading icon', async () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders labels list', async () => { + expect(findLabelsList().exists()).toBe(true); + expect(findLabels()).toHaveLength(2); + }); + }); + + it('first item is highlighted when search is not empty', async () => { + createComponent({ + queryHandler: jest.fn().mockResolvedValue(workspaceLabelsQueryResponse), + searchKey: 'Label', + }); + await makeObserverAppear(); + await waitForPromises(); + await nextTick(); + + expect(findLabelsList().exists()).toBe(true); + expect(findFirstLabel().attributes('active')).toBe('true'); + }); + + it('when search returns 0 results', async () => { + createComponent({ + queryHandler: jest.fn().mockResolvedValue({ + data: { + workspace: { + labels: { + nodes: [], + }, + }, + }, + }), + searchKey: '123', + }); + await makeObserverAppear(); + await waitForPromises(); + await nextTick(); + + expect(findNoResultsMessage().isVisible()).toBe(true); + }); + + it('calls `createAlert` when fetching labels failed', async () => { + createComponent({ queryHandler: jest.fn().mockRejectedValue('Houston, we have a problem!') }); + await makeObserverAppear(); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalled(); + }); + + it('emits an `input` event on label click', async () => { + createComponent(); + await makeObserverAppear(); + await waitForPromises(); + findFirstLabel().trigger('click'); + + expect(wrapper.emitted('input')[0][0]).toEqual(expect.arrayContaining(localSelectedLabels)); + }); + + it('does not trigger query when component did not appear', () => { + createComponent(); + expect(findLoadingIcon().exists()).toBe(false); + expect(findLabelsList().exists()).toBe(false); + expect(successfulQueryHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_spec.js new file mode 100644 index 00000000000..9bbb1413ee9 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_spec.js @@ -0,0 +1,207 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { DropdownVariant } from '~/sidebar/components/labels/labels_select_widget/constants'; +import DropdownContents from '~/sidebar/components/labels/labels_select_widget/dropdown_contents.vue'; +import DropdownContentsCreateView from '~/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue'; +import DropdownContentsLabelsView from '~/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue'; +import DropdownFooter from '~/sidebar/components/labels/labels_select_widget/dropdown_footer.vue'; + +import { mockLabels } from './mock_data'; + +const showDropdown = jest.fn(); +const focusInput = jest.fn(); + +const GlDropdownStub = { + template: ` + <div data-testid="dropdown"> + <slot name="header"></slot> + <slot></slot> + <slot name="footer"></slot> + </div> + `, + methods: { + show: showDropdown, + hide: jest.fn(), + }, +}; + +const DropdownHeaderStub = { + template: ` + <div>Hello, I am a header</div> + `, + methods: { + focusInput, + }, +}; + +describe('DropdownContent', () => { + let wrapper; + + const createComponent = ({ props = {}, data = {} } = {}) => { + wrapper = shallowMount(DropdownContents, { + propsData: { + labelsCreateTitle: 'test', + selectedLabels: mockLabels, + allowMultiselect: true, + labelsListTitle: 'Assign labels', + footerCreateLabelTitle: 'create', + footerManageLabelTitle: 'manage', + dropdownButtonText: 'Labels', + variant: 'sidebar', + fullPath: 'test', + workspaceType: 'project', + labelCreateType: 'project', + attrWorkspacePath: 'path', + ...props, + }, + data() { + return { + ...data, + }; + }, + stubs: { + GlDropdown: GlDropdownStub, + DropdownHeader: DropdownHeaderStub, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findCreateView = () => wrapper.findComponent(DropdownContentsCreateView); + const findLabelsView = () => wrapper.findComponent(DropdownContentsLabelsView); + const findDropdownHeader = () => wrapper.findComponent(DropdownHeaderStub); + const findDropdownFooter = () => wrapper.findComponent(DropdownFooter); + const findDropdown = () => wrapper.findComponent(GlDropdownStub); + + it('calls dropdown `show` method on `isVisible` prop change', async () => { + createComponent(); + await wrapper.setProps({ + isVisible: true, + }); + + expect(findDropdown().emitted('show')).toBeUndefined(); + }); + + it('does not emit `setLabels` event on dropdown hide if labels did not change', () => { + createComponent(); + findDropdown().vm.$emit('hide'); + + expect(wrapper.emitted('setLabels')).toBeUndefined(); + }); + + it('emits `setLabels` event on dropdown hide if labels changed on non-sidebar widget', async () => { + createComponent({ props: { variant: DropdownVariant.Standalone } }); + const updatedLabel = { + id: 28, + title: 'Bug', + description: 'Label for bugs', + color: '#FF0000', + textColor: '#FFFFFF', + }; + findLabelsView().vm.$emit('input', [updatedLabel]); + await nextTick(); + findDropdown().vm.$emit('hide'); + + expect(wrapper.emitted('setLabels')).toEqual([[[updatedLabel]]]); + }); + + it('emits `setLabels` event on visibility change if labels changed on sidebar widget', async () => { + createComponent({ props: { variant: DropdownVariant.Standalone, isVisible: true } }); + const updatedLabel = { + id: 28, + title: 'Bug', + description: 'Label for bugs', + color: '#FF0000', + textColor: '#FFFFFF', + }; + findLabelsView().vm.$emit('input', [updatedLabel]); + wrapper.setProps({ isVisible: false }); + await nextTick(); + + expect(wrapper.emitted('setLabels')).toEqual([[[updatedLabel]]]); + }); + + it('renders header', () => { + createComponent(); + + expect(findDropdownHeader().exists()).toBe(true); + }); + + it('sets searchKey for labels view on input event from header', async () => { + createComponent(); + + expect(findLabelsView().props('searchKey')).toBe(''); + findDropdownHeader().vm.$emit('input', '123'); + await nextTick(); + + expect(findLabelsView().props('searchKey')).toBe('123'); + }); + + it('clears and focuses search input on selecting a label', () => { + createComponent(); + findDropdownHeader().vm.$emit('input', '123'); + findLabelsView().vm.$emit('input', []); + + expect(findLabelsView().props('searchKey')).toBe(''); + expect(focusInput).toHaveBeenCalled(); + }); + + describe('Create view', () => { + beforeEach(() => { + createComponent({ data: { showDropdownContentsCreateView: true } }); + }); + + it('renders create view when `showDropdownContentsCreateView` prop is `true`', () => { + expect(findCreateView().exists()).toBe(true); + }); + + it('does not render footer', () => { + expect(findDropdownFooter().exists()).toBe(false); + }); + + it('changes the view to Labels view on `toggleDropdownContentsCreateView` event', async () => { + findDropdownHeader().vm.$emit('toggleDropdownContentsCreateView'); + await nextTick(); + + expect(findCreateView().exists()).toBe(false); + expect(findLabelsView().exists()).toBe(true); + }); + + it('changes the view to Labels view on `hideCreateView` event', async () => { + findCreateView().vm.$emit('hideCreateView'); + await nextTick(); + + expect(findCreateView().exists()).toBe(false); + expect(findLabelsView().exists()).toBe(true); + }); + }); + + describe('Labels view', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders labels view when `showDropdownContentsCreateView` when `showDropdownContentsCreateView` prop is `false`', () => { + expect(findLabelsView().exists()).toBe(true); + }); + + it('renders footer on sidebar dropdown', () => { + expect(findDropdownFooter().exists()).toBe(true); + }); + + it('does not render footer on standalone dropdown', () => { + createComponent({ props: { variant: DropdownVariant.Standalone } }); + + expect(findDropdownFooter().exists()).toBe(false); + }); + + it('renders footer on embedded dropdown', () => { + createComponent({ props: { variant: DropdownVariant.Embedded } }); + + expect(findDropdownFooter().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_footer_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_footer_spec.js new file mode 100644 index 00000000000..9a6e0ca3ccd --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_footer_spec.js @@ -0,0 +1,57 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import DropdownFooter from '~/sidebar/components/labels/labels_select_widget/dropdown_footer.vue'; + +describe('DropdownFooter', () => { + let wrapper; + + const createComponent = ({ props = {}, injected = {} } = {}) => { + wrapper = shallowMount(DropdownFooter, { + propsData: { + footerCreateLabelTitle: 'create', + footerManageLabelTitle: 'manage', + ...props, + }, + provide: { + allowLabelCreate: true, + labelsManagePath: 'foo/bar', + ...injected, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]'); + + describe('Labels view', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not render create label button if `allowLabelCreate` is false', () => { + createComponent({ injected: { allowLabelCreate: false } }); + + expect(findCreateLabelButton().exists()).toBe(false); + }); + + describe('when `allowLabelCreate` is true', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders create label button', () => { + expect(findCreateLabelButton().exists()).toBe(true); + }); + + it('emits `toggleDropdownContentsCreateView` event on create label button click', async () => { + findCreateLabelButton().trigger('click'); + + await nextTick(); + expect(wrapper.emitted('toggleDropdownContentsCreateView')).toEqual([[]]); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_header_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_header_spec.js new file mode 100644 index 00000000000..d9001dface4 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_header_spec.js @@ -0,0 +1,92 @@ +import { GlSearchBoxByType } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import DropdownHeader from '~/sidebar/components/labels/labels_select_widget/dropdown_header.vue'; + +describe('DropdownHeader', () => { + let wrapper; + + const createComponent = ({ + showDropdownContentsCreateView = false, + labelsFetchInProgress = false, + isStandalone = false, + } = {}) => { + wrapper = extendedWrapper( + shallowMount(DropdownHeader, { + propsData: { + showDropdownContentsCreateView, + labelsFetchInProgress, + labelsCreateTitle: 'Create label', + labelsListTitle: 'Select label', + searchKey: '', + isStandalone, + }, + stubs: { + GlSearchBoxByType, + }, + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findSearchInput = () => wrapper.findComponent(GlSearchBoxByType); + const findGoBackButton = () => wrapper.findByTestId('go-back-button'); + const findDropdownTitle = () => wrapper.findByTestId('dropdown-header-title'); + + beforeEach(() => { + createComponent(); + }); + + describe('Create view', () => { + beforeEach(() => { + createComponent({ showDropdownContentsCreateView: true }); + }); + + it('renders go back button', () => { + expect(findGoBackButton().exists()).toBe(true); + }); + + it('does not render search input field', async () => { + expect(findSearchInput().exists()).toBe(false); + }); + }); + + describe('Labels view', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not render go back button', () => { + expect(findGoBackButton().exists()).toBe(false); + }); + + it.each` + labelsFetchInProgress | disabled + ${true} | ${true} + ${false} | ${false} + `( + 'when labelsFetchInProgress is $labelsFetchInProgress, renders search input with disabled prop to $disabled', + ({ labelsFetchInProgress, disabled }) => { + createComponent({ labelsFetchInProgress }); + expect(findSearchInput().props('disabled')).toBe(disabled); + }, + ); + }); + + describe('Standalone variant', () => { + beforeEach(() => { + createComponent({ isStandalone: true }); + }); + + it('renders search input', () => { + expect(findSearchInput().exists()).toBe(true); + }); + + it('does not render title', async () => { + expect(findDropdownTitle().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js new file mode 100644 index 00000000000..585048983c9 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js @@ -0,0 +1,104 @@ +import { GlLabel } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; + +import DropdownValue from '~/sidebar/components/labels/labels_select_widget/dropdown_value.vue'; + +import { mockRegularLabel, mockScopedLabel } from './mock_data'; + +describe('DropdownValue', () => { + let wrapper; + + const findAllLabels = () => wrapper.findAllComponents(GlLabel); + const findRegularLabel = () => findAllLabels().at(1); + const findScopedLabel = () => findAllLabels().at(0); + const findWrapper = () => wrapper.find('[data-testid="value-wrapper"]'); + const findEmptyPlaceholder = () => wrapper.find('[data-testid="empty-placeholder"]'); + + const createComponent = (props = {}, slots = {}) => { + wrapper = shallowMount(DropdownValue, { + slots, + propsData: { + selectedLabels: [mockRegularLabel, mockScopedLabel], + allowLabelRemove: true, + labelsFilterBasePath: '/gitlab-org/my-project/issues', + labelsFilterParam: 'label_name', + ...props, + }, + provide: { + allowScopedLabels: true, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when there are no labels', () => { + beforeEach(() => { + createComponent( + { + selectedLabels: [], + }, + { + default: 'None', + }, + ); + }); + + it('does not apply `has-labels` class to the wrapping container', () => { + expect(findWrapper().classes()).not.toContain('has-labels'); + }); + + it('renders an empty placeholder', () => { + expect(findEmptyPlaceholder().exists()).toBe(true); + expect(findEmptyPlaceholder().text()).toBe('None'); + }); + + it('does not render any labels', () => { + expect(findAllLabels().length).toBe(0); + }); + }); + + describe('when there are labels', () => { + beforeEach(() => { + createComponent(); + }); + + it('applies `has-labels` class to the wrapping container', () => { + expect(findWrapper().classes()).toContain('has-labels'); + }); + + it('does not render an empty placeholder', () => { + expect(findEmptyPlaceholder().exists()).toBe(false); + }); + + it('renders a list of two labels', () => { + expect(findAllLabels().length).toBe(2); + }); + + it('passes correct props to the regular label', () => { + expect(findRegularLabel().props('target')).toBe( + '/gitlab-org/my-project/issues?label_name[]=Foo%20Label', + ); + expect(findRegularLabel().props('scoped')).toBe(false); + }); + + it('passes correct props to the scoped label', () => { + expect(findScopedLabel().props('target')).toBe( + '/gitlab-org/my-project/issues?label_name[]=Foo%3A%3ABar', + ); + expect(findScopedLabel().props('scoped')).toBe(true); + }); + + it('emits `onLabelRemove` event with the correct ID', () => { + findRegularLabel().vm.$emit('close'); + expect(wrapper.emitted('onLabelRemove')).toEqual([[mockRegularLabel.id]]); + }); + + it('emits `onCollapsedValueClick` when clicking on collapsed value', () => { + wrapper.find('.sidebar-collapsed-icon').trigger('click'); + expect(wrapper.emitted('onCollapsedValueClick')).toEqual([[]]); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js new file mode 100644 index 00000000000..4fa65c752f9 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js @@ -0,0 +1,77 @@ +import { GlLabel } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import EmbeddedLabelsList from '~/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue'; +import { mockRegularLabel, mockScopedLabel } from './mock_data'; + +describe('EmbeddedLabelsList', () => { + let wrapper; + + const findAllLabels = () => wrapper.findAllComponents(GlLabel); + const findLabelByTitle = (title) => + findAllLabels() + .filter((label) => label.props('title') === title) + .at(0); + const findRegularLabel = () => findLabelByTitle(mockRegularLabel.title); + const findScopedLabel = () => findLabelByTitle(mockScopedLabel.title); + + const createComponent = (props = {}, slots = {}) => { + wrapper = shallowMountExtended(EmbeddedLabelsList, { + slots, + propsData: { + selectedLabels: [mockRegularLabel, mockScopedLabel], + allowLabelRemove: true, + labelsFilterBasePath: '/gitlab-org/my-project/issues', + labelsFilterParam: 'label_name', + ...props, + }, + provide: { + allowScopedLabels: true, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when there are no labels', () => { + beforeEach(() => { + createComponent({ + selectedLabels: [], + }); + }); + + it('does not render any labels', () => { + expect(findAllLabels()).toHaveLength(0); + }); + }); + + describe('when there are labels', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders a list of two labels', () => { + expect(findAllLabels()).toHaveLength(2); + }); + + it('passes correct props to the regular label', () => { + expect(findRegularLabel().props('target')).toBe( + '/gitlab-org/my-project/issues?label_name[]=Foo%20Label', + ); + expect(findRegularLabel().props('scoped')).toBe(false); + }); + + it('passes correct props to the scoped label', () => { + expect(findScopedLabel().props('target')).toBe( + '/gitlab-org/my-project/issues?label_name[]=Foo%3A%3ABar', + ); + expect(findScopedLabel().props('scoped')).toBe(true); + }); + + it('emits `onLabelRemove` event with the correct ID', () => { + findRegularLabel().vm.$emit('close'); + expect(wrapper.emitted('onLabelRemove')).toStrictEqual([[mockRegularLabel.id]]); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/label_item_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/label_item_spec.js new file mode 100644 index 00000000000..74188a77994 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/label_item_spec.js @@ -0,0 +1,38 @@ +import { shallowMount } from '@vue/test-utils'; + +import LabelItem from '~/sidebar/components/labels/labels_select_widget/label_item.vue'; +import { mockRegularLabel } from './mock_data'; + +const mockLabel = { ...mockRegularLabel, set: true }; + +const createComponent = ({ label = mockLabel } = {}) => + shallowMount(LabelItem, { + propsData: { + label, + }, + }); + +describe('LabelItem', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + it('renders label color element', () => { + const colorEl = wrapper.find('[data-testid="label-color-box"]'); + + expect(colorEl.exists()).toBe(true); + expect(colorEl.attributes('style')).toBe('background-color: rgb(186, 218, 85);'); + }); + + it('renders label title', () => { + expect(wrapper.text()).toContain(mockLabel.title); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/labels_select_root_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/labels_select_root_spec.js new file mode 100644 index 00000000000..2995c268966 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/labels_select_root_spec.js @@ -0,0 +1,267 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/flash'; +import { IssuableType } from '~/issues/constants'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import DropdownContents from '~/sidebar/components/labels/labels_select_widget/dropdown_contents.vue'; +import DropdownValue from '~/sidebar/components/labels/labels_select_widget/dropdown_value.vue'; +import EmbeddedLabelsList from '~/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue'; +import issueLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql'; +import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; +import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql'; +import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql'; +import updateEpicLabelsMutation from '~/sidebar/components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql'; +import LabelsSelectRoot from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue'; +import { + mockConfig, + issuableLabelsQueryResponse, + updateLabelsMutationResponse, + issuableLabelsSubscriptionResponse, + mockLabels, + mockRegularLabel, +} from './mock_data'; + +jest.mock('~/flash'); + +Vue.use(VueApollo); + +const successfulQueryHandler = jest.fn().mockResolvedValue(issuableLabelsQueryResponse); +const successfulMutationHandler = jest.fn().mockResolvedValue(updateLabelsMutationResponse); +const subscriptionHandler = jest.fn().mockResolvedValue(issuableLabelsSubscriptionResponse); +const errorQueryHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); + +const updateLabelsMutation = { + [IssuableType.Issue]: updateIssueLabelsMutation, + [IssuableType.MergeRequest]: updateMergeRequestLabelsMutation, + [IssuableType.Epic]: updateEpicLabelsMutation, +}; + +describe('LabelsSelectRoot', () => { + let wrapper; + + const findSidebarEditableItem = () => wrapper.findComponent(SidebarEditableItem); + const findDropdownValue = () => wrapper.findComponent(DropdownValue); + const findDropdownContents = () => wrapper.findComponent(DropdownContents); + const findEmbeddedLabelsList = () => wrapper.findComponent(EmbeddedLabelsList); + + const createComponent = ({ + config = mockConfig, + slots = {}, + issuableType = IssuableType.Issue, + queryHandler = successfulQueryHandler, + mutationHandler = successfulMutationHandler, + } = {}) => { + const mockApollo = createMockApollo([ + [issueLabelsQuery, queryHandler], + [updateLabelsMutation[issuableType], mutationHandler], + [issuableLabelsSubscription, subscriptionHandler], + ]); + + wrapper = shallowMount(LabelsSelectRoot, { + slots, + apolloProvider: mockApollo, + propsData: { + ...config, + issuableType, + labelCreateType: 'project', + workspaceType: 'project', + }, + stubs: { + SidebarEditableItem, + }, + provide: { + canUpdate: true, + allowLabelEdit: true, + allowLabelCreate: true, + labelsManagePath: 'test', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders component with classes `labels-select-wrapper gl-relative`', () => { + createComponent(); + expect(wrapper.classes()).toEqual(['labels-select-wrapper', 'gl-relative']); + }); + + it.each` + variant | cssClass + ${'standalone'} | ${'is-standalone'} + ${'embedded'} | ${'is-embedded'} + `( + 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"', + async ({ variant, cssClass }) => { + createComponent({ + config: { ...mockConfig, variant }, + }); + + await nextTick(); + expect(wrapper.classes()).toContain(cssClass); + }, + ); + + describe('if dropdown variant is `sidebar`', () => { + it('renders sidebar editable item', () => { + createComponent(); + expect(findSidebarEditableItem().exists()).toBe(true); + }); + + it('passes true `loading` prop to sidebar editable item when loading labels', () => { + createComponent(); + expect(findSidebarEditableItem().props('loading')).toBe(true); + }); + + describe('when labels are fetched successfully', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('passes true `loading` prop to sidebar editable item', () => { + expect(findSidebarEditableItem().props('loading')).toBe(false); + }); + + it('renders dropdown value component when query labels is resolved', () => { + expect(findDropdownValue().exists()).toBe(true); + expect(findDropdownValue().props('selectedLabels')).toEqual([ + { + __typename: 'Label', + color: '#330066', + description: null, + id: 'gid://gitlab/ProjectLabel/1', + title: 'Label1', + textColor: '#000000', + }, + ]); + }); + + it('emits `onLabelRemove` event on dropdown value label remove event', () => { + const label = { id: 'gid://gitlab/ProjectLabel/1' }; + findDropdownValue().vm.$emit('onLabelRemove', label); + expect(wrapper.emitted('onLabelRemove')).toEqual([[label]]); + }); + }); + + it('creates flash with error message when query is rejected', async () => { + createComponent({ queryHandler: errorQueryHandler }); + await waitForPromises(); + expect(createAlert).toHaveBeenCalledWith({ message: 'Error fetching labels.' }); + }); + }); + + describe('if dropdown variant is `embedded`', () => { + it('shows the embedded labels list', () => { + createComponent({ + config: { ...mockConfig, iid: '', variant: 'embedded', showEmbeddedLabelsList: true }, + }); + + expect(findEmbeddedLabelsList().props()).toMatchObject({ + disabled: false, + selectedLabels: [], + allowLabelRemove: false, + labelsFilterBasePath: mockConfig.labelsFilterBasePath, + labelsFilterParam: mockConfig.labelsFilterParam, + }); + }); + + it('passes the selected labels if provided', () => { + createComponent({ + config: { + ...mockConfig, + iid: '', + variant: 'embedded', + showEmbeddedLabelsList: true, + selectedLabels: mockLabels, + }, + }); + + expect(findEmbeddedLabelsList().props('selectedLabels')).toStrictEqual(mockLabels); + expect(findDropdownContents().props('selectedLabels')).toStrictEqual(mockLabels); + }); + + it('emits the `onLabelRemove` when the embedded list triggers a removal', () => { + createComponent({ + config: { + ...mockConfig, + iid: '', + variant: 'embedded', + showEmbeddedLabelsList: true, + selectedLabels: [mockRegularLabel], + }, + }); + + findEmbeddedLabelsList().vm.$emit('onLabelRemove', [mockRegularLabel.id]); + expect(wrapper.emitted('onLabelRemove')).toStrictEqual([[[mockRegularLabel.id]]]); + }); + }); + + it('emits `updateSelectedLabels` event on dropdown contents `setLabels` event if iid is not set', async () => { + const label = { id: 'gid://gitlab/ProjectLabel/1' }; + createComponent({ config: { ...mockConfig, iid: undefined } }); + + findDropdownContents().vm.$emit('setLabels', [label]); + expect(wrapper.emitted('updateSelectedLabels')).toEqual([[{ labels: [label] }]]); + }); + + describe.each` + issuableType + ${IssuableType.Issue} + ${IssuableType.MergeRequest} + ${IssuableType.Epic} + `('when updating labels for $issuableType', ({ issuableType }) => { + const label = { id: 'gid://gitlab/ProjectLabel/2' }; + + it('sets the loading state', async () => { + createComponent({ issuableType }); + await nextTick(); + findDropdownContents().vm.$emit('setLabels', [label]); + await nextTick(); + + expect(findSidebarEditableItem().props('loading')).toBe(true); + }); + + it('updates labels correctly after successful mutation', async () => { + createComponent({ issuableType }); + await nextTick(); + findDropdownContents().vm.$emit('setLabels', [label]); + await waitForPromises(); + + expect(findDropdownValue().props('selectedLabels')).toEqual( + updateLabelsMutationResponse.data.updateIssuableLabels.issuable.labels.nodes, + ); + }); + + it('displays an error if mutation was rejected', async () => { + createComponent({ issuableType, mutationHandler: errorQueryHandler }); + await nextTick(); + findDropdownContents().vm.$emit('setLabels', [label]); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + captureError: true, + error: expect.anything(), + message: 'An error occurred while updating labels.', + }); + }); + + it('emits `updateSelectedLabels` event when the subscription is triggered', async () => { + createComponent(); + await waitForPromises(); + + expect(wrapper.emitted('updateSelectedLabels')).toEqual([ + [ + { + id: '1', + labels: issuableLabelsSubscriptionResponse.data.issuableLabelsUpdated.labels.nodes, + }, + ], + ]); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js b/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js new file mode 100644 index 00000000000..48530a0261f --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js @@ -0,0 +1,185 @@ +export const mockRegularLabel = { + id: 26, + title: 'Foo Label', + description: 'Foobar', + color: '#BADA55', + textColor: '#FFFFFF', +}; + +export const mockScopedLabel = { + id: 27, + title: 'Foo::Bar', + description: 'Foobar', + color: '#0033CC', + textColor: '#FFFFFF', +}; + +export const mockLabels = [ + mockRegularLabel, + mockScopedLabel, + { + id: 28, + title: 'Bug', + description: 'Label for bugs', + color: '#FF0000', + textColor: '#FFFFFF', + }, + { + id: 29, + title: 'Boog', + description: 'Label for bugs', + color: '#FF0000', + textColor: '#FFFFFF', + }, +]; + +export const mockConfig = { + iid: '1', + fullPath: 'test', + allowMultiselect: true, + labelsListTitle: 'Assign labels', + labelsCreateTitle: 'Create label', + variant: 'sidebar', + labelsSelectInProgress: false, + labelsFilterBasePath: '/gitlab-org/my-project/issues', + labelsFilterParam: 'label_name', + footerCreateLabelTitle: 'create', + footerManageLabelTitle: 'manage', + attrWorkspacePath: 'test', +}; + +export const mockSuggestedColors = { + '#009966': 'Green-cyan', + '#8fbc8f': 'Dark sea green', + '#3cb371': 'Medium sea green', + '#00b140': 'Green screen', + '#013220': 'Dark green', + '#6699cc': 'Blue-gray', + '#0000ff': 'Blue', + '#e6e6fa': 'Lavender', + '#9400d3': 'Dark violet', + '#330066': 'Deep violet', + '#808080': 'Gray', + '#36454f': 'Charcoal grey', + '#f7e7ce': 'Champagne', + '#c21e56': 'Rose red', + '#cc338b': 'Magenta-pink', + '#dc143c': 'Crimson', + '#ff0000': 'Red', + '#cd5b45': 'Dark coral', + '#eee600': 'Titanium yellow', + '#ed9121': 'Carrot orange', + '#c39953': 'Aztec Gold', +}; + +export const createLabelSuccessfulResponse = { + data: { + labelCreate: { + label: { + id: 'gid://gitlab/ProjectLabel/126', + color: '#dc143c', + description: null, + title: 'ewrwrwer', + textColor: '#000000', + __typename: 'Label', + }, + errors: [], + __typename: 'LabelCreatePayload', + }, + }, +}; + +export const workspaceLabelsQueryResponse = { + data: { + workspace: { + id: 'gid://gitlab/Project/126', + labels: { + nodes: [ + { + __typename: 'Label', + color: '#330066', + description: null, + id: 'gid://gitlab/ProjectLabel/1', + title: 'Label1', + textColor: '#000000', + }, + { + __typename: 'Label', + color: '#2f7b2e', + description: null, + id: 'gid://gitlab/ProjectLabel/2', + title: 'Label2', + textColor: '#000000', + }, + ], + }, + }, + }, +}; + +export const issuableLabelsQueryResponse = { + data: { + workspace: { + id: 'workspace-1', + issuable: { + __typename: 'Issue', + id: '1', + labels: { + nodes: [ + { + __typename: 'Label', + color: '#330066', + description: null, + id: 'gid://gitlab/ProjectLabel/1', + title: 'Label1', + textColor: '#000000', + }, + ], + }, + }, + }, + }, +}; + +export const issuableLabelsSubscriptionResponse = { + data: { + issuableLabelsUpdated: { + id: '1', + labels: { + nodes: [ + { + __typename: 'Label', + color: '#330066', + description: null, + id: 'gid://gitlab/ProjectLabel/1', + title: 'Label1', + textColor: '#000000', + }, + { + __typename: 'Label', + color: '#000000', + description: null, + id: 'gid://gitlab/ProjectLabel/2', + title: 'Label2', + textColor: '#ffffff', + }, + ], + }, + }, + }, +}; + +export const updateLabelsMutationResponse = { + data: { + updateIssuableLabels: { + errors: [], + issuable: { + __typename: 'Issue', + id: '1', + labels: { + nodes: [], + }, + }, + }, + }, +}; diff --git a/spec/frontend/sidebar/lock/__snapshots__/edit_form_spec.js.snap b/spec/frontend/sidebar/components/lock/__snapshots__/edit_form_spec.js.snap index 18d4df297df..18d4df297df 100644 --- a/spec/frontend/sidebar/lock/__snapshots__/edit_form_spec.js.snap +++ b/spec/frontend/sidebar/components/lock/__snapshots__/edit_form_spec.js.snap diff --git a/spec/frontend/sidebar/lock/constants.js b/spec/frontend/sidebar/components/lock/constants.js index b9f08e9286d..b9f08e9286d 100644 --- a/spec/frontend/sidebar/lock/constants.js +++ b/spec/frontend/sidebar/components/lock/constants.js diff --git a/spec/frontend/sidebar/lock/edit_form_buttons_spec.js b/spec/frontend/sidebar/components/lock/edit_form_buttons_spec.js index 2abb0c24d7d..2abb0c24d7d 100644 --- a/spec/frontend/sidebar/lock/edit_form_buttons_spec.js +++ b/spec/frontend/sidebar/components/lock/edit_form_buttons_spec.js diff --git a/spec/frontend/sidebar/lock/edit_form_spec.js b/spec/frontend/sidebar/components/lock/edit_form_spec.js index 4ae9025ee39..4ae9025ee39 100644 --- a/spec/frontend/sidebar/lock/edit_form_spec.js +++ b/spec/frontend/sidebar/components/lock/edit_form_spec.js diff --git a/spec/frontend/sidebar/lock/issuable_lock_form_spec.js b/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js index 8f825847cfc..8f825847cfc 100644 --- a/spec/frontend/sidebar/lock/issuable_lock_form_spec.js +++ b/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js diff --git a/spec/frontend/sidebar/components/move/issuable_move_dropdown_spec.js b/spec/frontend/sidebar/components/move/issuable_move_dropdown_spec.js new file mode 100644 index 00000000000..72279f44e80 --- /dev/null +++ b/spec/frontend/sidebar/components/move/issuable_move_dropdown_spec.js @@ -0,0 +1,388 @@ +import { + GlIcon, + GlLoadingIcon, + GlDropdown, + GlDropdownForm, + GlDropdownItem, + GlSearchBoxByType, + GlButton, +} from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; + +import { nextTick } from 'vue'; +import axios from '~/lib/utils/axios_utils'; +import IssuableMoveDropdown from '~/sidebar/components/move/issuable_move_dropdown.vue'; + +const mockProjects = [ + { + id: 2, + name_with_namespace: 'Gitlab Org / Gitlab Shell', + full_path: 'gitlab-org/gitlab-shell', + }, + { + id: 3, + name_with_namespace: 'Gnuwget / Wget2', + full_path: 'gnuwget/wget2', + }, + { + id: 4, + name_with_namespace: 'Commit451 / Lab Coat', + full_path: 'Commit451/lab-coat', + }, +]; + +const mockProps = { + projectsFetchPath: '/-/autocomplete/projects?project_id=1', + dropdownButtonTitle: 'Move issuable', + dropdownHeaderTitle: 'Move issuable', + moveInProgress: false, + disabled: false, +}; + +const mockEvent = { + stopPropagation: jest.fn(), + preventDefault: jest.fn(), +}; + +describe('IssuableMoveDropdown', () => { + let mock; + let wrapper; + + const createComponent = (propsData = mockProps) => { + wrapper = shallowMount(IssuableMoveDropdown, { + propsData, + }); + wrapper.vm.$refs.dropdown.hide = jest.fn(); + wrapper.vm.$refs.searchInput.focusInput = jest.fn(); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + describe('watch', () => { + describe('searchKey', () => { + it('calls `fetchProjects` with value of the prop', async () => { + jest.spyOn(wrapper.vm, 'fetchProjects'); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + searchKey: 'foo', + }); + + await nextTick(); + + expect(wrapper.vm.fetchProjects).toHaveBeenCalledWith('foo'); + }); + }); + }); + + describe('methods', () => { + describe('fetchProjects', () => { + it('sets projectsListLoading to true and projectsListLoadFailed to false', () => { + wrapper.vm.fetchProjects(); + + expect(wrapper.vm.projectsListLoading).toBe(true); + expect(wrapper.vm.projectsListLoadFailed).toBe(false); + }); + + it('calls `axios.get` with `projectsFetchPath` and query param `search`', () => { + jest.spyOn(axios, 'get').mockResolvedValue({ + data: mockProjects, + }); + + wrapper.vm.fetchProjects('foo'); + + expect(axios.get).toHaveBeenCalledWith( + mockProps.projectsFetchPath, + expect.objectContaining({ + params: { + search: 'foo', + }, + }), + ); + }); + + it('sets response to `projects` and focuses on searchInput when request is successful', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ + data: mockProjects, + }); + + await wrapper.vm.fetchProjects('foo'); + + expect(wrapper.vm.projects).toBe(mockProjects); + expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled(); + }); + + it('sets projectsListLoadFailed to true when request fails', async () => { + jest.spyOn(axios, 'get').mockRejectedValue({}); + + await wrapper.vm.fetchProjects('foo'); + + expect(wrapper.vm.projectsListLoadFailed).toBe(true); + }); + + it('sets projectsListLoading to false when request completes', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ + data: mockProjects, + }); + + await wrapper.vm.fetchProjects('foo'); + + expect(wrapper.vm.projectsListLoading).toBe(false); + }); + }); + + describe('isSelectedProject', () => { + it.each` + project | selectedProject | title | returnValue + ${mockProjects[0]} | ${mockProjects[0]} | ${'are same projects'} | ${true} + ${mockProjects[0]} | ${mockProjects[1]} | ${'are different projects'} | ${false} + `( + 'returns $returnValue when selectedProject and provided project param $title', + async ({ project, selectedProject, returnValue }) => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + selectedProject, + }); + + await nextTick(); + + expect(wrapper.vm.isSelectedProject(project)).toBe(returnValue); + }, + ); + + it('returns false when selectedProject is null', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + selectedProject: null, + }); + + await nextTick(); + + expect(wrapper.vm.isSelectedProject(mockProjects[0])).toBe(false); + }); + }); + }); + + describe('template', () => { + const findDropdownEl = () => wrapper.findComponent(GlDropdown); + + it('renders collapsed state element with icon', () => { + const collapsedEl = wrapper.find('[data-testid="move-collapsed"]'); + + expect(collapsedEl.exists()).toBe(true); + expect(collapsedEl.attributes('title')).toBe(mockProps.dropdownButtonTitle); + expect(collapsedEl.findComponent(GlIcon).exists()).toBe(true); + expect(collapsedEl.findComponent(GlIcon).props('name')).toBe('arrow-right'); + }); + + describe('gl-dropdown component', () => { + it('renders component container element', () => { + expect(findDropdownEl().exists()).toBe(true); + expect(findDropdownEl().props('block')).toBe(true); + }); + + it('renders gl-dropdown-form component', () => { + expect(findDropdownEl().findComponent(GlDropdownForm).exists()).toBe(true); + }); + + it('renders disabled dropdown when `disabled` is true', () => { + createComponent({ ...mockProps, disabled: true }); + + expect(findDropdownEl().attributes('disabled')).toBe('true'); + }); + + it('renders header element', () => { + const headerEl = findDropdownEl().find('[data-testid="header"]'); + + expect(headerEl.exists()).toBe(true); + expect(headerEl.find('span').text()).toBe(mockProps.dropdownHeaderTitle); + expect(headerEl.findComponent(GlButton).props('icon')).toBe('close'); + }); + + it('renders gl-search-box-by-type component', () => { + const searchEl = findDropdownEl().findComponent(GlSearchBoxByType); + + expect(searchEl.exists()).toBe(true); + expect(searchEl.attributes()).toMatchObject({ + placeholder: 'Search project', + debounce: '300', + }); + }); + + it('renders gl-loading-icon component when projectsListLoading prop is true', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + projectsListLoading: true, + }); + + await nextTick(); + + expect(findDropdownEl().findComponent(GlLoadingIcon).exists()).toBe(true); + }); + + it('renders gl-dropdown-item components for available projects', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + projects: mockProjects, + selectedProject: mockProjects[0], + }); + + await nextTick(); + + const dropdownItems = wrapper.findAllComponents(GlDropdownItem); + + expect(dropdownItems).toHaveLength(mockProjects.length); + expect(dropdownItems.at(0).props()).toMatchObject({ + isCheckItem: true, + isChecked: true, + }); + expect(dropdownItems.at(0).text()).toBe(mockProjects[0].name_with_namespace); + }); + + it('renders string "No matching results" when search does not yield any matches', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + searchKey: 'foo', + }); + + // Wait for `searchKey` watcher to run. + await nextTick(); + + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + projects: [], + projectsListLoading: false, + }); + + await nextTick(); + + const dropdownContentEl = wrapper.find('[data-testid="content"]'); + + expect(dropdownContentEl.text()).toContain('No matching results'); + }); + + it('renders string "Failed to load projects" when loading projects list fails', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + projects: [], + projectsListLoading: false, + projectsListLoadFailed: true, + }); + + await nextTick(); + + const dropdownContentEl = wrapper.find('[data-testid="content"]'); + + expect(dropdownContentEl.text()).toContain('Failed to load projects'); + }); + + it('renders gl-button within footer', async () => { + const moveButtonEl = wrapper.find('[data-testid="footer"]').findComponent(GlButton); + + expect(moveButtonEl.text()).toBe('Move'); + expect(moveButtonEl.attributes('disabled')).toBe('true'); + + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + selectedProject: mockProjects[0], + }); + + await nextTick(); + + expect( + wrapper.find('[data-testid="footer"]').findComponent(GlButton).attributes('disabled'), + ).not.toBeDefined(); + }); + }); + + describe('events', () => { + it('collapsed state element emits `toggle-collapse` event on component when clicked', () => { + wrapper.find('[data-testid="move-collapsed"]').trigger('click'); + + expect(wrapper.emitted('toggle-collapse')).toHaveLength(1); + }); + + it('gl-dropdown component calls `fetchProjects` on `shown` event', () => { + jest.spyOn(axios, 'get').mockResolvedValue({ + data: mockProjects, + }); + + findDropdownEl().vm.$emit('shown'); + + expect(axios.get).toHaveBeenCalled(); + }); + + it('gl-dropdown component prevents dropdown body from closing on `hide` event when `projectItemClick` prop is true', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + projectItemClick: true, + }); + + findDropdownEl().vm.$emit('hide', mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(wrapper.vm.projectItemClick).toBe(false); + }); + + it('gl-dropdown component emits `dropdown-close` event on component from `hide` event', async () => { + findDropdownEl().vm.$emit('hide'); + + expect(wrapper.emitted('dropdown-close')).toHaveLength(1); + }); + + it('close icon in dropdown header closes the dropdown when clicked', () => { + wrapper.find('[data-testid="header"]').findComponent(GlButton).vm.$emit('click', mockEvent); + + expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalled(); + }); + + it('sets project for clicked gl-dropdown-item to selectedProject', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + projects: mockProjects, + }); + + await nextTick(); + + wrapper.findAllComponents(GlDropdownItem).at(0).vm.$emit('click', mockEvent); + + expect(wrapper.vm.selectedProject).toBe(mockProjects[0]); + }); + + it('hides dropdown and emits `move-issuable` event when move button is clicked', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + selectedProject: mockProjects[0], + }); + + await nextTick(); + + wrapper.find('[data-testid="footer"]').findComponent(GlButton).vm.$emit('click'); + + expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalled(); + expect(wrapper.emitted('move-issuable')).toHaveLength(1); + expect(wrapper.emitted('move-issuable')[0]).toEqual([mockProjects[0]]); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/move/move_issues_button_spec.js b/spec/frontend/sidebar/components/move/move_issues_button_spec.js new file mode 100644 index 00000000000..999340da27c --- /dev/null +++ b/spec/frontend/sidebar/components/move/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 { createAlert } from '~/flash'; +import { logError } from '~/lib/logger'; +import IssuableMoveDropdown from '~/sidebar/components/move/issuable_move_dropdown.vue'; +import issuableEventHub from '~/issues/list/eventhub'; +import MoveIssuesButton from '~/sidebar/components/move/move_issues_button.vue'; +import moveIssueMutation from '~/sidebar/queries/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(createAlert).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(createAlert).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(createAlert).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(createAlert).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(createAlert).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(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).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(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).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, + }, + }); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/participants_spec.js b/spec/frontend/sidebar/components/participants/participants_spec.js index f7a626a189c..f7a626a189c 100644 --- a/spec/frontend/sidebar/participants_spec.js +++ b/spec/frontend/sidebar/components/participants/participants_spec.js diff --git a/spec/frontend/sidebar/reviewer_title_spec.js b/spec/frontend/sidebar/components/reviewers/reviewer_title_spec.js index 68ecd62e4c6..68ecd62e4c6 100644 --- a/spec/frontend/sidebar/reviewer_title_spec.js +++ b/spec/frontend/sidebar/components/reviewers/reviewer_title_spec.js diff --git a/spec/frontend/sidebar/reviewers_spec.js b/spec/frontend/sidebar/components/reviewers/reviewers_spec.js index 229f7ffbe04..229f7ffbe04 100644 --- a/spec/frontend/sidebar/reviewers_spec.js +++ b/spec/frontend/sidebar/components/reviewers/reviewers_spec.js diff --git a/spec/frontend/sidebar/components/reviewers/sidebar_reviewers_spec.js b/spec/frontend/sidebar/components/reviewers/sidebar_reviewers_spec.js new file mode 100644 index 00000000000..57ae146a27a --- /dev/null +++ b/spec/frontend/sidebar/components/reviewers/sidebar_reviewers_spec.js @@ -0,0 +1,77 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import axios from 'axios'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import SidebarReviewers from '~/sidebar/components/reviewers/sidebar_reviewers.vue'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import Mock from '../../mock_data'; + +Vue.use(VueApollo); + +describe('sidebar reviewers', () => { + const apolloMock = createMockApollo(); + let wrapper; + let mediator; + let axiosMock; + + const createComponent = (props) => { + wrapper = shallowMount(SidebarReviewers, { + apolloProvider: apolloMock, + propsData: { + issuableIid: '1', + issuableId: 1, + mediator, + field: '', + projectPath: 'projectPath', + changing: false, + ...props, + }, + // Attaching to document is required because this component emits something from the parent element :/ + attachTo: document.body, + }); + }; + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + mediator = new SidebarMediator(Mock.mediator); + + jest.spyOn(mediator, 'saveReviewers'); + jest.spyOn(mediator, 'addSelfReview'); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + axiosMock.restore(); + }); + + it('calls the mediator when it saves the reviewers', () => { + createComponent(); + + expect(mediator.saveReviewers).not.toHaveBeenCalled(); + + wrapper.vm.saveReviewers(); + + expect(mediator.saveReviewers).toHaveBeenCalled(); + }); + + it('calls the mediator when "reviewBySelf" method is called', () => { + createComponent(); + + expect(mediator.addSelfReview).not.toHaveBeenCalled(); + expect(mediator.store.reviewers.length).toBe(0); + + wrapper.vm.reviewBySelf(); + + expect(mediator.addSelfReview).toHaveBeenCalled(); + expect(mediator.store.reviewers.length).toBe(1); + }); +}); diff --git a/spec/frontend/sidebar/components/severity/severity_spec.js b/spec/frontend/sidebar/components/severity/severity_spec.js index 2146155791e..99d33e840d5 100644 --- a/spec/frontend/sidebar/components/severity/severity_spec.js +++ b/spec/frontend/sidebar/components/severity/severity_spec.js @@ -1,6 +1,6 @@ import { GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants'; +import { INCIDENT_SEVERITY } from '~/sidebar/constants'; import SeverityToken from '~/sidebar/components/severity/severity.vue'; describe('SeverityToken', () => { diff --git a/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js b/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js index bdea33371d8..8f936240b7a 100644 --- a/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js +++ b/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js @@ -3,8 +3,8 @@ import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; -import { INCIDENT_SEVERITY, ISSUABLE_TYPES } from '~/sidebar/components/severity/constants'; -import updateIssuableSeverity from '~/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql'; +import { INCIDENT_SEVERITY, ISSUABLE_TYPES } from '~/sidebar/constants'; +import updateIssuableSeverity from '~/sidebar/queries/update_issuable_severity.mutation.graphql'; import SeverityToken from '~/sidebar/components/severity/severity.vue'; import SidebarSeverity from '~/sidebar/components/severity/sidebar_severity.vue'; diff --git a/spec/frontend/sidebar/components/status/status_dropdown_spec.js b/spec/frontend/sidebar/components/status/status_dropdown_spec.js new file mode 100644 index 00000000000..5a75299c3a4 --- /dev/null +++ b/spec/frontend/sidebar/components/status/status_dropdown_spec.js @@ -0,0 +1,75 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import StatusDropdown from '~/sidebar/components/status/status_dropdown.vue'; +import { statusDropdownOptions } from '~/sidebar/constants'; + +describe('SubscriptionsDropdown component', () => { + let wrapper; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findHiddenInput = () => wrapper.find('input'); + + function createComponent() { + wrapper = shallowMount(StatusDropdown); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('with no value selected', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders default text', () => { + expect(findDropdown().props('text')).toBe('Select status'); + }); + + it('renders dropdown items with `is-checked` prop set to `false`', () => { + const dropdownItems = findAllDropdownItems(); + + expect(dropdownItems.at(0).props('isChecked')).toBe(false); + expect(dropdownItems.at(1).props('isChecked')).toBe(false); + }); + }); + + describe('when selecting a value', () => { + const selectItemAtIndex = 0; + + beforeEach(async () => { + createComponent(); + await findAllDropdownItems().at(selectItemAtIndex).vm.$emit('click'); + }); + + it('updates value of the hidden input', () => { + expect(findHiddenInput().attributes('value')).toBe( + statusDropdownOptions[selectItemAtIndex].value, + ); + }); + + it('updates the dropdown text prop', () => { + expect(findDropdown().props('text')).toBe(statusDropdownOptions[selectItemAtIndex].text); + }); + + it('sets dropdown item `is-checked` prop to `true`', () => { + const dropdownItems = findAllDropdownItems(); + + expect(dropdownItems.at(0).props('isChecked')).toBe(true); + expect(dropdownItems.at(1).props('isChecked')).toBe(false); + }); + + describe('when selecting the value that is already selected', () => { + it('clears dropdown selection', async () => { + await findAllDropdownItems().at(selectItemAtIndex).vm.$emit('click'); + + const dropdownItems = findAllDropdownItems(); + + expect(dropdownItems.at(0).props('isChecked')).toBe(false); + expect(dropdownItems.at(1).props('isChecked')).toBe(false); + expect(findDropdown().props('text')).toBe('Select status'); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/subscriptions/subscriptions_dropdown_spec.js b/spec/frontend/sidebar/components/subscriptions/subscriptions_dropdown_spec.js new file mode 100644 index 00000000000..3fb8214606c --- /dev/null +++ b/spec/frontend/sidebar/components/subscriptions/subscriptions_dropdown_spec.js @@ -0,0 +1,76 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import SubscriptionsDropdown from '~/sidebar/components/subscriptions/subscriptions_dropdown.vue'; +import { subscriptionsDropdownOptions } from '~/sidebar/constants'; + +describe('SubscriptionsDropdown component', () => { + let wrapper; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findHiddenInput = () => wrapper.find('input'); + + function createComponent() { + wrapper = shallowMount(SubscriptionsDropdown); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('with no value selected', () => { + beforeEach(() => { + createComponent(); + }); + + it('hidden input value is undefined', () => { + expect(findHiddenInput().attributes('value')).toBeUndefined(); + }); + + it('renders default text', () => { + expect(findDropdown().props('text')).toBe(SubscriptionsDropdown.i18n.defaultDropdownText); + }); + + it('renders dropdown items with `is-checked` prop set to `false`', () => { + const dropdownItems = findAllDropdownItems(); + + expect(dropdownItems.at(0).props('isChecked')).toBe(false); + expect(dropdownItems.at(1).props('isChecked')).toBe(false); + }); + }); + + describe('when selecting a value', () => { + beforeEach(() => { + createComponent(); + findAllDropdownItems().at(0).vm.$emit('click'); + }); + + it('updates value of the hidden input', () => { + expect(findHiddenInput().attributes('value')).toBe(subscriptionsDropdownOptions[0].value); + }); + + it('updates the dropdown text prop', () => { + expect(findDropdown().props('text')).toBe(subscriptionsDropdownOptions[0].text); + }); + + it('sets dropdown item `is-checked` prop to `true`', () => { + const dropdownItems = findAllDropdownItems(); + + expect(dropdownItems.at(0).props('isChecked')).toBe(true); + expect(dropdownItems.at(1).props('isChecked')).toBe(false); + }); + + describe('when selecting the value that is already selected', () => { + it('clears dropdown selection', async () => { + findAllDropdownItems().at(0).vm.$emit('click'); + await nextTick(); + const dropdownItems = findAllDropdownItems(); + + expect(dropdownItems.at(0).props('isChecked')).toBe(false); + expect(dropdownItems.at(1).props('isChecked')).toBe(false); + expect(findDropdown().props('text')).toBe(SubscriptionsDropdown.i18n.defaultDropdownText); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/subscriptions_spec.js b/spec/frontend/sidebar/components/subscriptions/subscriptions_spec.js index 1a1aa370eef..1a1aa370eef 100644 --- a/spec/frontend/sidebar/subscriptions_spec.js +++ b/spec/frontend/sidebar/components/subscriptions/subscriptions_spec.js diff --git a/spec/frontend/sidebar/components/time_tracking/create_timelog_form_spec.js b/spec/frontend/sidebar/components/time_tracking/create_timelog_form_spec.js new file mode 100644 index 00000000000..cb3bb7a4538 --- /dev/null +++ b/spec/frontend/sidebar/components/time_tracking/create_timelog_form_spec.js @@ -0,0 +1,219 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlAlert, GlModal } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import CreateTimelogForm from '~/sidebar/components/time_tracking/create_timelog_form.vue'; +import createTimelogMutation from '~/sidebar/queries/create_timelog.mutation.graphql'; +import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; + +const mockMutationErrorMessage = 'Example error message'; + +const resolvedMutationWithoutErrorsMock = jest.fn().mockResolvedValue({ + data: { + timelogCreate: { + errors: [], + timelog: { + id: 'gid://gitlab/Timelog/1', + issue: {}, + mergeRequest: {}, + }, + }, + }, +}); + +const resolvedMutationWithErrorsMock = jest.fn().mockResolvedValue({ + data: { + timelogCreate: { + errors: [{ message: mockMutationErrorMessage }], + timelog: null, + }, + }, +}); + +const rejectedMutationMock = jest.fn().mockRejectedValue(); +const modalCloseMock = jest.fn(); + +describe('Create Timelog Form', () => { + Vue.use(VueApollo); + + let wrapper; + let fakeApollo; + + const findForm = () => wrapper.find('form'); + const findModal = () => wrapper.findComponent(GlModal); + const findAlert = () => wrapper.findComponent(GlAlert); + const findDocsLink = () => wrapper.findByTestId('timetracking-docs-link'); + const findSaveButton = () => findModal().props('actionPrimary'); + const findSaveButtonLoadingState = () => findSaveButton().attributes[0].loading; + const findSaveButtonDisabledState = () => findSaveButton().attributes[0].disabled; + + const submitForm = () => findForm().trigger('submit'); + + const mountComponent = ( + { props, data, providedProps } = {}, + mutationResolverMock = rejectedMutationMock, + ) => { + fakeApollo = createMockApollo([[createTimelogMutation, mutationResolverMock]]); + + wrapper = shallowMountExtended(CreateTimelogForm, { + data() { + return { + ...data, + }; + }, + provide: { + issuableType: 'issue', + ...providedProps, + }, + propsData: { + issuableId: '1', + ...props, + }, + apolloProvider: fakeApollo, + }); + + wrapper.vm.$refs.modal.close = modalCloseMock; + }; + + afterEach(() => { + fakeApollo = null; + }); + + describe('save button', () => { + it('is disabled and not loading by default', () => { + mountComponent(); + + expect(findSaveButtonLoadingState()).toBe(false); + expect(findSaveButtonDisabledState()).toBe(true); + }); + + it('is enabled and not loading when time spent is not empty', () => { + mountComponent({ data: { timeSpent: '2d' } }); + + expect(findSaveButtonLoadingState()).toBe(false); + expect(findSaveButtonDisabledState()).toBe(false); + }); + + it('is disabled and loading when the the form is submitted', async () => { + mountComponent({ data: { timeSpent: '2d' } }); + + submitForm(); + + await nextTick(); + + expect(findSaveButtonLoadingState()).toBe(true); + expect(findSaveButtonDisabledState()).toBe(true); + }); + + it('is enabled and not loading the when form is submitted but the mutation has errors', async () => { + mountComponent({ data: { timeSpent: '2d' } }); + + submitForm(); + + await waitForPromises(); + + expect(rejectedMutationMock).toHaveBeenCalled(); + expect(findSaveButtonLoadingState()).toBe(false); + expect(findSaveButtonDisabledState()).toBe(false); + }); + + it('is enabled and not loading the when form is submitted but the mutation returns errors', async () => { + mountComponent({ data: { timeSpent: '2d' } }, resolvedMutationWithErrorsMock); + + submitForm(); + + await waitForPromises(); + + expect(resolvedMutationWithErrorsMock).toHaveBeenCalled(); + expect(findSaveButtonLoadingState()).toBe(false); + expect(findSaveButtonDisabledState()).toBe(false); + }); + }); + + describe('form', () => { + it('does not call any mutation when the the form is incomplete', async () => { + mountComponent(); + + submitForm(); + + await waitForPromises(); + + expect(rejectedMutationMock).not.toHaveBeenCalled(); + }); + + it('closes the modal after a successful mutation', async () => { + mountComponent({ data: { timeSpent: '2d' } }, resolvedMutationWithoutErrorsMock); + + submitForm(); + + await waitForPromises(); + await nextTick(); + + expect(modalCloseMock).toHaveBeenCalled(); + }); + + it.each` + issuableType | typeConstant + ${'issue'} | ${TYPE_ISSUE} + ${'merge_request'} | ${TYPE_MERGE_REQUEST} + `( + 'calls the mutation with all the fields when the the form is submitted and issuable type is $issuableType', + async ({ issuableType, typeConstant }) => { + const timeSpent = '2d'; + const spentAt = '2022-11-20T21:53:00+0000'; + const summary = 'Example'; + + mountComponent({ data: { timeSpent, spentAt, summary }, providedProps: { issuableType } }); + + submitForm(); + + await waitForPromises(); + + expect(rejectedMutationMock).toHaveBeenCalledWith({ + input: { timeSpent, spentAt, summary, issuableId: convertToGraphQLId(typeConstant, '1') }, + }); + }, + ); + }); + + describe('alert', () => { + it('is hidden by default', () => { + mountComponent(); + + expect(findAlert().exists()).toBe(false); + }); + + it('shows an error if the submission fails with a handled error', async () => { + mountComponent({ data: { timeSpent: '2d' } }, resolvedMutationWithErrorsMock); + + submitForm(); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(mockMutationErrorMessage); + }); + + it('shows an error if the submission fails with an unhandled error', async () => { + mountComponent({ data: { timeSpent: '2d' } }); + + submitForm(); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe('An error occurred while saving the time entry.'); + }); + }); + + describe('docs link message', () => { + it('is present', () => { + mountComponent(); + + expect(findDocsLink().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/time_tracking/report_spec.js b/spec/frontend/sidebar/components/time_tracking/report_spec.js index af72122052f..0259aee48f0 100644 --- a/spec/frontend/sidebar/components/time_tracking/report_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/report_spec.js @@ -8,9 +8,9 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; import Report from '~/sidebar/components/time_tracking/report.vue'; -import getIssueTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql'; -import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql'; -import deleteTimelogMutation from '~/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql'; +import getIssueTimelogsQuery from '~/sidebar/queries/get_issue_timelogs.query.graphql'; +import getMrTimelogsQuery from '~/sidebar/queries/get_mr_timelogs.query.graphql'; +import deleteTimelogMutation from '~/sidebar/queries/delete_timelog.mutation.graphql'; import { getIssueTimelogsQueryResponse, getMrTimelogsQueryResponse, diff --git a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js index 835e700e63c..45d8b5e4647 100644 --- a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js @@ -268,47 +268,32 @@ describe('Issuable Time Tracker', () => { }); }); - describe('Help pane', () => { - const findHelpButton = () => findByTestId('helpButton'); - const findCloseHelpButton = () => findByTestId('closeHelpButton'); - - beforeEach(async () => { - wrapper = mountComponent({ - props: { - initialTimeTracking: { - timeEstimate: 0, - totalTimeSpent: 0, - humanTimeEstimate: '', - humanTotalTimeSpent: '', + describe('Add button', () => { + const findAddButton = () => findByTestId('add-time-entry-button'); + + it.each` + visibility | canAddTimeEntries + ${'not visible'} | ${false} + ${'visible'} | ${true} + `( + 'is $visibility when canAddTimeEntries is $canAddTimeEntries', + async ({ canAddTimeEntries }) => { + wrapper = mountComponent({ + props: { + initialTimeTracking: { + timeEstimate: 0, + totalTimeSpent: 0, + humanTimeEstimate: '', + humanTotalTimeSpent: '', + }, + canAddTimeEntries, }, - }, - }); - await nextTick(); - }); - - it('should not show the "Help" pane by default', () => { - expect(findByTestId('helpPane').exists()).toBe(false); - }); - - it('should show the "Help" pane when help button is clicked', async () => { - findHelpButton().trigger('click'); - - await nextTick(); - - expect(findByTestId('helpPane').exists()).toBe(true); - }); - - it('should not show the "Help" pane when help button is clicked and then closed', async () => { - findHelpButton().trigger('click'); - await nextTick(); - - expect(findByTestId('helpPane').exists()).toBe(true); - - findCloseHelpButton().trigger('click'); - await nextTick(); + }); + await nextTick(); - expect(findByTestId('helpPane').exists()).toBe(false); - }); + expect(findAddButton().exists()).toBe(canAddTimeEntries); + }, + ); }); }); diff --git a/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap b/spec/frontend/sidebar/components/todo_toggle/__snapshots__/todo_spec.js.snap index 846f45345e7..846f45345e7 100644 --- a/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap +++ b/spec/frontend/sidebar/components/todo_toggle/__snapshots__/todo_spec.js.snap diff --git a/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js b/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js index f73491ca95f..5bfe3b59eb3 100644 --- a/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js +++ b/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js @@ -7,7 +7,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import epicTodoQuery from '~/sidebar/queries/epic_todo.query.graphql'; -import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue'; +import TodoButton from '~/sidebar/components/todo_toggle/todo_button.vue'; import { todosResponse, noTodosResponse } from '../../mock_data'; jest.mock('~/flash'); diff --git a/spec/frontend/sidebar/components/todo_toggle/todo_button_spec.js b/spec/frontend/sidebar/components/todo_toggle/todo_button_spec.js new file mode 100644 index 00000000000..fb07029a249 --- /dev/null +++ b/spec/frontend/sidebar/components/todo_toggle/todo_button_spec.js @@ -0,0 +1,68 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; +import TodoButton from '~/sidebar/components/todo_toggle/todo_button.vue'; + +describe('Todo Button', () => { + let wrapper; + let dispatchEventSpy; + + const createComponent = (props = {}, mountFn = shallowMount) => { + wrapper = mountFn(TodoButton, { + propsData: { + ...props, + }, + }); + }; + + beforeEach(() => { + dispatchEventSpy = jest.spyOn(document, 'dispatchEvent'); + jest.spyOn(document, 'querySelector').mockReturnValue({ + innerText: 2, + }); + }); + + afterEach(() => { + wrapper.destroy(); + dispatchEventSpy = null; + jest.clearAllMocks(); + }); + + it('renders GlButton', () => { + createComponent(); + + expect(wrapper.findComponent(GlButton).exists()).toBe(true); + }); + + it('emits click event when clicked', () => { + createComponent({}, mount); + wrapper.findComponent(GlButton).trigger('click'); + + expect(wrapper.emitted().click).toHaveLength(1); + }); + + it('calls dispatchDocumentEvent to update global To-Do counter correctly', () => { + createComponent({}, mount); + wrapper.findComponent(GlButton).trigger('click'); + const dispatchedEvent = dispatchEventSpy.mock.calls[0][0]; + + expect(dispatchEventSpy).toHaveBeenCalledTimes(1); + expect(dispatchedEvent.detail).toEqual({ count: 1 }); + expect(dispatchedEvent.type).toBe('todo:toggle'); + }); + + it.each` + label | isTodo + ${'Mark as done'} | ${true} + ${'Add a to do'} | ${false} + `('sets correct label when isTodo is $isTodo', ({ label, isTodo }) => { + createComponent({ isTodo }); + + expect(wrapper.findComponent(GlButton).text()).toBe(label); + }); + + it('binds additional props to GlButton', () => { + createComponent({ loading: true }); + + expect(wrapper.findComponent(GlButton).props('loading')).toBe(true); + }); +}); diff --git a/spec/frontend/sidebar/todo_spec.js b/spec/frontend/sidebar/components/todo_toggle/todo_spec.js index 8e6597bf80f..8e6597bf80f 100644 --- a/spec/frontend/sidebar/todo_spec.js +++ b/spec/frontend/sidebar/components/todo_toggle/todo_spec.js diff --git a/spec/frontend/sidebar/components/toggle/toggle_sidebar_spec.js b/spec/frontend/sidebar/components/toggle/toggle_sidebar_spec.js new file mode 100644 index 00000000000..cf9b2828dde --- /dev/null +++ b/spec/frontend/sidebar/components/toggle/toggle_sidebar_spec.js @@ -0,0 +1,46 @@ +import { GlButton } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; + +import { nextTick } from 'vue'; +import ToggleSidebar from '~/sidebar/components/toggle/toggle_sidebar.vue'; + +describe('ToggleSidebar', () => { + let wrapper; + + const defaultProps = { + collapsed: true, + }; + + const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => { + wrapper = mountFn(ToggleSidebar, { + propsData: { ...defaultProps, ...props }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findGlButton = () => wrapper.findComponent(GlButton); + + it('should render the "chevron-double-lg-left" icon when collapsed', () => { + createComponent(); + + expect(findGlButton().props('icon')).toBe('chevron-double-lg-left'); + }); + + it('should render the "chevron-double-lg-right" icon when expanded', async () => { + createComponent({ props: { collapsed: false } }); + + expect(findGlButton().props('icon')).toBe('chevron-double-lg-right'); + }); + + it('should emit toggle event when button clicked', async () => { + createComponent({ mountFn: mount }); + + findGlButton().trigger('click'); + await nextTick(); + + expect(wrapper.emitted('toggle')[0]).toBeDefined(); + }); +}); diff --git a/spec/frontend/sidebar/sidebar_move_issue_spec.js b/spec/frontend/sidebar/lib/sidebar_move_issue_spec.js index 195cc6ddeeb..6e365df329b 100644 --- a/spec/frontend/sidebar/sidebar_move_issue_spec.js +++ b/spec/frontend/sidebar/lib/sidebar_move_issue_spec.js @@ -8,7 +8,7 @@ import SidebarService from '~/sidebar/services/sidebar_service'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarStore from '~/sidebar/stores/sidebar_store'; import { GitLabDropdown } from '~/deprecated_jquery_dropdown/gl_dropdown'; -import Mock from './mock_data'; +import Mock from '../mock_data'; jest.mock('~/flash'); diff --git a/spec/frontend/sidebar/sidebar_mediator_spec.js b/spec/frontend/sidebar/sidebar_mediator_spec.js index bb5e7f7ff16..cdb9ced70b8 100644 --- a/spec/frontend/sidebar/sidebar_mediator_spec.js +++ b/spec/frontend/sidebar/sidebar_mediator_spec.js @@ -24,7 +24,8 @@ describe('Sidebar mediator', () => { SidebarService.singleton = null; SidebarStore.singleton = null; SidebarMediator.singleton = null; - mock.restore(); + + jest.clearAllMocks(); }); it('assigns yourself', () => { @@ -42,6 +43,52 @@ describe('Sidebar mediator', () => { }); }); + it('assigns yourself as a reviewer', () => { + mediator.addSelfReview(); + + expect(mediator.store.currentUser).toEqual(mediatorMockData.currentUser); + expect(mediator.store.reviewers[0]).toEqual(mediatorMockData.currentUser); + }); + + describe('saves reviewers', () => { + const mockUpdateResponseData = { + reviewers: [1, 2], + assignees: [3, 4], + }; + const field = 'merge_request[reviewers_ids]'; + const reviewers = [ + { id: 1, suggested: true }, + { id: 2, suggested: false }, + ]; + + let serviceSpy; + + beforeEach(() => { + mediator.store.reviewers = reviewers; + serviceSpy = jest + .spyOn(mediator.service, 'update') + .mockReturnValue(Promise.resolve({ data: mockUpdateResponseData })); + }); + + it('sends correct data to service', () => { + const data = { + reviewer_ids: [1, 2], + suggested_reviewer_ids: [1], + }; + + mediator.saveReviewers(field); + + expect(serviceSpy).toHaveBeenCalledWith(field, data); + }); + + it('saves reviewers', () => { + return mediator.saveReviewers(field).then(() => { + expect(mediator.store.assignees).toEqual(mockUpdateResponseData.assignees); + expect(mediator.store.reviewers).toEqual(mockUpdateResponseData.reviewers); + }); + }); + }); + it('fetches the data', async () => { const mockData = Mock.responseMap.GET[mediatorMockData.endpoint]; mock.onGet(mediatorMockData.endpoint).reply(200, mockData); @@ -49,7 +96,6 @@ describe('Sidebar mediator', () => { await mediator.fetch(); expect(spy).toHaveBeenCalledWith(mockData); - spy.mockRestore(); }); it('processes fetched data', () => { @@ -70,8 +116,6 @@ describe('Sidebar mediator', () => { mediator.setMoveToProjectId(projectId); expect(spy).toHaveBeenCalledWith(projectId); - - spy.mockRestore(); }); it('fetches autocomplete projects', () => { @@ -87,9 +131,6 @@ describe('Sidebar mediator', () => { return mediator.fetchAutocompleteProjects(searchTerm).then(() => { expect(getterSpy).toHaveBeenCalledWith(searchTerm); expect(setterSpy).toHaveBeenCalled(); - - getterSpy.mockRestore(); - setterSpy.mockRestore(); }); }); @@ -106,9 +147,6 @@ describe('Sidebar mediator', () => { return mediator.moveIssue().then(() => { expect(moveIssueSpy).toHaveBeenCalledWith(moveToProjectId); expect(urlSpy).toHaveBeenCalledWith(mockData.web_url); - - moveIssueSpy.mockRestore(); - urlSpy.mockRestore(); }); }); }); diff --git a/spec/frontend/sidebar/sidebar_store_spec.js b/spec/frontend/sidebar/stores/sidebar_store_spec.js index 3930dabfcfa..3f4b80409c2 100644 --- a/spec/frontend/sidebar/sidebar_store_spec.js +++ b/spec/frontend/sidebar/stores/sidebar_store_spec.js @@ -1,6 +1,6 @@ import UsersMockHelper from 'helpers/user_mock_data_helper'; import SidebarStore from '~/sidebar/stores/sidebar_store'; -import Mock from './mock_data'; +import Mock from '../mock_data'; const ASSIGNEE = { id: 2, |