diff options
Diffstat (limited to 'spec/frontend/sidebar')
12 files changed, 1019 insertions, 67 deletions
diff --git a/spec/frontend/sidebar/assignees_realtime_spec.js b/spec/frontend/sidebar/assignees_realtime_spec.js index 0fab6a29f71..f0a6fa40d67 100644 --- a/spec/frontend/sidebar/assignees_realtime_spec.js +++ b/spec/frontend/sidebar/assignees_realtime_spec.js @@ -1,7 +1,7 @@ import ActionCable from '@rails/actioncable'; import { shallowMount } from '@vue/test-utils'; -import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql'; import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; +import { assigneesQueries } from '~/sidebar/constants'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import Mock from './mock_data'; @@ -18,18 +18,19 @@ describe('Assignees Realtime', () => { let wrapper; let mediator; - const createComponent = () => { + const createComponent = (issuableType = 'issue') => { wrapper = shallowMount(AssigneesRealtime, { propsData: { issuableIid: '1', mediator, projectPath: 'path/to/project', + issuableType, }, mocks: { $apollo: { - query, + query: assigneesQueries[issuableType].query, queries: { - project: { + workspace: { refetch: jest.fn(), }, }, @@ -51,8 +52,8 @@ describe('Assignees Realtime', () => { describe('when handleFetchResult is called from smart query', () => { it('sets assignees to the store', () => { const data = { - project: { - issue: { + workspace: { + issuable: { assignees: { nodes: [{ id: 'gid://gitlab/Environments/123', avatarUrl: 'url' }], }, @@ -95,7 +96,7 @@ describe('Assignees Realtime', () => { wrapper.vm.received({ event: 'updated' }); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.$apollo.queries.project.refetch).toHaveBeenCalledTimes(1); + expect(wrapper.vm.$apollo.queries.workspace.refetch).toHaveBeenCalledTimes(1); }); }); }); diff --git a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js new file mode 100644 index 00000000000..824f6d49c65 --- /dev/null +++ b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js @@ -0,0 +1,558 @@ +import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { cloneDeep } from 'lodash'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphql'; +import { IssuableType } from '~/issue_show/constants'; +import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; +import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; +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 { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants'; +import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; +import getIssueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; +import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql'; +import { + issuableQueryResponse, + searchQueryResponse, + updateIssueAssigneesMutationResponse, +} from '../../mock_data'; + +jest.mock('~/flash'); + +const updateIssueAssigneesMutationSuccess = jest + .fn() + .mockResolvedValue(updateIssueAssigneesMutationResponse); +const mockError = jest.fn().mockRejectedValue('Error!'); + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +const initialAssignees = [ + { + id: 'some-user', + avatarUrl: 'some-user-avatar', + name: 'test', + username: 'test', + webUrl: '/test', + }, +]; + +describe('Sidebar assignees widget', () => { + let wrapper; + let fakeApollo; + + const findAssignees = () => wrapper.findComponent(IssuableAssignees); + const findRealtimeAssignees = () => wrapper.findComponent(SidebarAssigneesRealtime); + const findEditableItem = () => wrapper.findComponent(SidebarEditableItem); + const findDropdown = () => wrapper.findComponent(MultiSelectDropdown); + const findInviteMembersLink = () => wrapper.findComponent(SidebarInviteMembers); + const findSearchField = () => wrapper.findComponent(GlSearchBoxByType); + + const findParticipantsLoading = () => wrapper.find('[data-testid="loading-participants"]'); + const findSelectedParticipants = () => wrapper.findAll('[data-testid="selected-participant"]'); + const findUnselectedParticipants = () => + wrapper.findAll('[data-testid="unselected-participant"]'); + const findCurrentUser = () => wrapper.findAll('[data-testid="current-user"]'); + const findUnassignLink = () => wrapper.find('[data-testid="unassign"]'); + const findEmptySearchResults = () => wrapper.find('[data-testid="empty-results"]'); + + const expandDropdown = () => wrapper.vm.$refs.toggle.expand(); + + const createComponent = ({ + search = '', + issuableQueryHandler = jest.fn().mockResolvedValue(issuableQueryResponse), + searchQueryHandler = jest.fn().mockResolvedValue(searchQueryResponse), + updateIssueAssigneesMutationHandler = updateIssueAssigneesMutationSuccess, + props = {}, + provide = {}, + } = {}) => { + fakeApollo = createMockApollo([ + [getIssueParticipantsQuery, issuableQueryHandler], + [searchUsersQuery, searchQueryHandler], + [updateIssueAssigneesMutation, updateIssueAssigneesMutationHandler], + ]); + wrapper = shallowMount(SidebarAssigneesWidget, { + localVue, + apolloProvider: fakeApollo, + propsData: { + iid: '1', + fullPath: '/mygroup/myProject', + ...props, + }, + data() { + return { + search, + selected: [], + }; + }, + provide: { + canUpdate: true, + rootPath: '/', + ...provide, + }, + stubs: { + SidebarEditableItem, + MultiSelectDropdown, + GlSearchBoxByType, + GlDropdown, + }, + }); + }; + + beforeEach(() => { + gon.current_username = 'root'; + gon.current_user_fullname = 'Administrator'; + gon.current_user_avatar_url = '/root'; + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + fakeApollo = null; + delete gon.current_username; + }); + + describe('with passed initial assignees', () => { + it('passes `initialLoading` as false to editable item', () => { + createComponent({ + props: { + initialAssignees, + }, + }); + + expect(findEditableItem().props('initialLoading')).toBe(false); + }); + + it('renders an initial assignees list with initialAssignees prop', () => { + createComponent({ + props: { + initialAssignees, + }, + }); + + expect(findAssignees().props('users')).toEqual(initialAssignees); + }); + + it('renders a collapsible item title calculated with initial assignees length', () => { + createComponent({ + props: { + initialAssignees, + }, + }); + + expect(findEditableItem().props('title')).toBe('Assignee'); + }); + + describe('when expanded', () => { + it('renders a loading spinner if participants are loading', () => { + createComponent({ + props: { + initialAssignees, + }, + }); + expandDropdown(); + + expect(findParticipantsLoading().exists()).toBe(true); + }); + }); + }); + + describe('without passed initial assignees', () => { + it('passes `initialLoading` as true to editable item', () => { + createComponent(); + + expect(findEditableItem().props('initialLoading')).toBe(true); + }); + + it('renders assignees list from API response when resolved', async () => { + createComponent(); + await waitForPromises(); + + expect(findAssignees().props('users')).toEqual( + issuableQueryResponse.data.workspace.issuable.assignees.nodes, + ); + }); + + it('renders an error when issuable query is rejected', async () => { + createComponent({ + issuableQueryHandler: mockError, + }); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'An error occurred while fetching participants.', + }); + }); + + it('assigns current user when clicking `Assign self`', async () => { + createComponent(); + + await waitForPromises(); + + findAssignees().vm.$emit('assign-self'); + + expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ + assigneeUsernames: 'root', + fullPath: '/mygroup/myProject', + iid: '1', + }); + + await waitForPromises(); + + expect( + findAssignees() + .props('users') + .some((user) => user.username === 'root'), + ).toBe(true); + }); + + it('emits an event with assignees list on successful mutation', async () => { + createComponent(); + + await waitForPromises(); + + findAssignees().vm.$emit('assign-self'); + + expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ + assigneeUsernames: 'root', + fullPath: '/mygroup/myProject', + iid: '1', + }); + + await waitForPromises(); + + expect(wrapper.emitted('assignees-updated')).toEqual([ + [ + [ + { + __typename: 'User', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 'gid://gitlab/User/1', + name: 'Administrator', + username: 'root', + webUrl: '/root', + status: null, + }, + ], + ], + ]); + }); + + it('renders current user if they are not in participants or assignees', async () => { + gon.current_username = 'random'; + gon.current_user_fullname = 'Mr Random'; + gon.current_user_avatar_url = '/random'; + + createComponent(); + await waitForPromises(); + expandDropdown(); + + expect(findCurrentUser().exists()).toBe(true); + }); + + describe('when expanded', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + expandDropdown(); + }); + + it('collapses the widget on multiselect dropdown toggle event', async () => { + findDropdown().vm.$emit('toggle'); + await nextTick(); + expect(findDropdown().isVisible()).toBe(false); + }); + + it('renders participants list with correct amount of selected and unselected', async () => { + expect(findSelectedParticipants()).toHaveLength(1); + expect(findUnselectedParticipants()).toHaveLength(2); + }); + + it('does not render current user if they are in participants', () => { + expect(findCurrentUser().exists()).toBe(false); + }); + + it('unassigns all participants when clicking on `Unassign`', () => { + findUnassignLink().vm.$emit('click'); + findEditableItem().vm.$emit('close'); + + expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ + assigneeUsernames: [], + fullPath: '/mygroup/myProject', + iid: '1', + }); + }); + }); + + describe('when multiselect is disabled', () => { + beforeEach(async () => { + createComponent({ props: { multipleAssignees: false } }); + await waitForPromises(); + expandDropdown(); + }); + + it('adds a single assignee when clicking on unselected user', async () => { + findUnselectedParticipants().at(0).vm.$emit('click'); + + expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ + assigneeUsernames: ['root'], + fullPath: '/mygroup/myProject', + iid: '1', + }); + }); + + it('removes an assignee when clicking on selected user', () => { + findSelectedParticipants().at(0).vm.$emit('click', new Event('click')); + + expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ + assigneeUsernames: [], + fullPath: '/mygroup/myProject', + iid: '1', + }); + }); + }); + + describe('when multiselect is enabled', () => { + beforeEach(async () => { + createComponent({ props: { multipleAssignees: true } }); + await waitForPromises(); + expandDropdown(); + }); + + it('adds a few assignees after clicking on unselected users and closing a dropdown', () => { + findUnselectedParticipants().at(0).vm.$emit('click'); + findUnselectedParticipants().at(1).vm.$emit('click'); + findEditableItem().vm.$emit('close'); + + expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ + assigneeUsernames: ['francina.skiles', 'root', 'johndoe'], + fullPath: '/mygroup/myProject', + iid: '1', + }); + }); + + it('removes an assignee when clicking on selected user and then closing dropdown', () => { + findSelectedParticipants().at(0).vm.$emit('click', new Event('click')); + + findEditableItem().vm.$emit('close'); + + expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ + assigneeUsernames: [], + fullPath: '/mygroup/myProject', + iid: '1', + }); + }); + + it('does not call a mutation when clicking on participants until dropdown is closed', () => { + findUnselectedParticipants().at(0).vm.$emit('click'); + findSelectedParticipants().at(0).vm.$emit('click', new Event('click')); + + expect(updateIssueAssigneesMutationSuccess).not.toHaveBeenCalled(); + }); + }); + + it('shows an error if update assignees mutation is rejected', async () => { + createComponent({ updateIssueAssigneesMutationHandler: mockError }); + await waitForPromises(); + expandDropdown(); + + findUnassignLink().vm.$emit('click'); + findEditableItem().vm.$emit('close'); + + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'An error occurred while updating assignees.', + }); + }); + + describe('when searching', () => { + it('does not show loading spinner when debounce timer is still running', async () => { + createComponent({ search: 'roo' }); + await waitForPromises(); + expandDropdown(); + + expect(findParticipantsLoading().exists()).toBe(false); + }); + + it('shows loading spinner when searching for users', async () => { + createComponent({ search: 'roo' }); + await waitForPromises(); + expandDropdown(); + jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); + await nextTick(); + + expect(findParticipantsLoading().exists()).toBe(true); + }); + + it('renders a list of found users and external participants matching search term', async () => { + const responseCopy = cloneDeep(issuableQueryResponse); + responseCopy.data.workspace.issuable.participants.nodes.push({ + id: 'gid://gitlab/User/5', + avatarUrl: '/someavatar', + name: 'Roodie', + username: 'roodie', + webUrl: '/roodie', + status: null, + }); + + const issuableQueryHandler = jest.fn().mockResolvedValue(responseCopy); + + createComponent({ issuableQueryHandler }); + await waitForPromises(); + expandDropdown(); + + findSearchField().vm.$emit('input', 'roo'); + await nextTick(); + + jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); + await nextTick(); + await waitForPromises(); + + expect(findUnselectedParticipants()).toHaveLength(3); + }); + + it('renders a list of found users only if no external participants match search term', async () => { + createComponent({ search: 'roo' }); + await waitForPromises(); + expandDropdown(); + jest.advanceTimersByTime(250); + await nextTick(); + await waitForPromises(); + + expect(findUnselectedParticipants()).toHaveLength(2); + }); + + it('shows a message about no matches if search returned an empty list', async () => { + const responseCopy = cloneDeep(searchQueryResponse); + responseCopy.data.workspace.users.nodes = []; + + createComponent({ + search: 'roo', + searchQueryHandler: jest.fn().mockResolvedValue(responseCopy), + }); + await waitForPromises(); + expandDropdown(); + jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); + await nextTick(); + await waitForPromises(); + + expect(findUnselectedParticipants()).toHaveLength(0); + expect(findEmptySearchResults().exists()).toBe(true); + }); + + it('shows an error if search query was rejected', async () => { + createComponent({ search: 'roo', searchQueryHandler: mockError }); + await waitForPromises(); + expandDropdown(); + jest.advanceTimersByTime(250); + await nextTick(); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'An error occurred while searching users.', + }); + }); + }); + }); + + describe('when user is not signed in', () => { + beforeEach(() => { + gon.current_username = undefined; + createComponent(); + }); + + it('does not show current user in the dropdown', () => { + expandDropdown(); + expect(findCurrentUser().exists()).toBe(false); + }); + + it('passes signedIn prop as false to IssuableAssignees', () => { + expect(findAssignees().props('signedIn')).toBe(false); + }); + }); + + it('when realtime feature flag is disabled', async () => { + createComponent(); + await waitForPromises(); + expect(findRealtimeAssignees().exists()).toBe(false); + }); + + it('when realtime feature flag is enabled', async () => { + createComponent({ + provide: { + glFeatures: { + realTimeIssueSidebar: true, + }, + }, + }); + await waitForPromises(); + expect(findRealtimeAssignees().exists()).toBe(true); + }); + + describe('when making changes to participants list', () => { + beforeEach(async () => { + createComponent(); + }); + + it('passes falsy `isDirty` prop to editable item if no changes to selected users were made', () => { + expandDropdown(); + expect(findEditableItem().props('isDirty')).toBe(false); + }); + + it('passes truthy `isDirty` prop if selected users list was changed', async () => { + expandDropdown(); + expect(findEditableItem().props('isDirty')).toBe(false); + findUnselectedParticipants().at(0).vm.$emit('click'); + await nextTick(); + expect(findEditableItem().props('isDirty')).toBe(true); + }); + + it('passes falsy `isDirty` prop after dropdown is closed', async () => { + expandDropdown(); + findUnselectedParticipants().at(0).vm.$emit('click'); + findEditableItem().vm.$emit('close'); + await waitForPromises(); + expect(findEditableItem().props('isDirty')).toBe(false); + }); + }); + + it('does not render invite members link on non-issue sidebar', async () => { + createComponent({ props: { issuableType: IssuableType.MergeRequest } }); + await waitForPromises(); + expect(findInviteMembersLink().exists()).toBe(false); + }); + + it('does not render invite members link if `directlyInviteMembers` and `indirectlyInviteMembers` were not passed', async () => { + createComponent(); + await waitForPromises(); + expect(findInviteMembersLink().exists()).toBe(false); + }); + + it('renders invite members link if `directlyInviteMembers` is true', async () => { + createComponent({ + provide: { + directlyInviteMembers: true, + }, + }); + await waitForPromises(); + expect(findInviteMembersLink().exists()).toBe(true); + }); + + it('renders invite members link if `indirectlyInviteMembers` is true', async () => { + createComponent({ + provide: { + indirectlyInviteMembers: true, + }, + }); + await waitForPromises(); + expect(findInviteMembersLink().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js index 4ee12838491..84b192aaf41 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js @@ -5,7 +5,7 @@ import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue' describe('boards sidebar remove issue', () => { let wrapper; - const findLoader = () => wrapper.find(GlLoadingIcon); + const findLoader = () => wrapper.findComponent(GlLoadingIcon); const findEditButton = () => wrapper.find('[data-testid="edit-button"]'); const findTitle = () => wrapper.find('[data-testid="title"]'); const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]'); @@ -117,4 +117,35 @@ describe('boards sidebar remove issue', () => { expect(wrapper.emitted().close).toBeUndefined(); }); + + it('renders `Edit` test when passed `isDirty` prop is false', () => { + createComponent({ props: { isDirty: false }, canUpdate: true }); + + expect(findEditButton().text()).toBe('Edit'); + }); + + it('renders `Apply` test when passed `isDirty` prop is true', () => { + createComponent({ props: { isDirty: true }, canUpdate: true }); + + expect(findEditButton().text()).toBe('Apply'); + }); + + describe('when initial loading is true', () => { + beforeEach(() => { + createComponent({ props: { initialLoading: true } }); + }); + + it('renders loading icon', () => { + expect(findLoader().exists()).toBe(true); + }); + + it('does not render edit button', () => { + expect(findEditButton().exists()).toBe(false); + }); + + it('does not render collapsed and expanded content', () => { + expect(findCollapsed().exists()).toBe(false); + expect(findExpanded().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js new file mode 100644 index 00000000000..06f7da3d1ab --- /dev/null +++ b/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js @@ -0,0 +1,59 @@ +import { shallowMount } from '@vue/test-utils'; +import InviteMemberModal from '~/invite_member/components/invite_member_modal.vue'; +import InviteMemberTrigger from '~/invite_member/components/invite_member_trigger.vue'; +import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; +import SidebarInviteMembers from '~/sidebar/components/assignees/sidebar_invite_members.vue'; + +const testProjectMembersPath = 'test-path'; + +describe('Sidebar invite members component', () => { + let wrapper; + + const findDirectInviteLink = () => wrapper.findComponent(InviteMembersTrigger); + const findIndirectInviteLink = () => wrapper.findComponent(InviteMemberTrigger); + const findInviteModal = () => wrapper.findComponent(InviteMemberModal); + + const createComponent = ({ directlyInviteMembers = false } = {}) => { + wrapper = shallowMount(SidebarInviteMembers, { + provide: { + directlyInviteMembers, + projectMembersPath: testProjectMembersPath, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when directly inviting members', () => { + beforeEach(() => { + createComponent({ directlyInviteMembers: true }); + }); + + it('renders a direct link to project members path', () => { + expect(findDirectInviteLink().exists()).toBe(true); + }); + + it('does not render invite members trigger and modal components', () => { + expect(findIndirectInviteLink().exists()).toBe(false); + expect(findInviteModal().exists()).toBe(false); + }); + }); + + describe('when indirectly inviting members', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not render a direct link to project members path', () => { + expect(findDirectInviteLink().exists()).toBe(false); + }); + + it('does not render invite members trigger and modal components', () => { + expect(findIndirectInviteLink().exists()).toBe(true); + expect(findInviteModal().exists()).toBe(true); + expect(findInviteModal().props('membersPath')).toBe(testProjectMembersPath); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js new file mode 100644 index 00000000000..88a5f4ea8b7 --- /dev/null +++ b/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js @@ -0,0 +1,43 @@ +import { GlAvatarLabeled } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; + +const user = { + name: 'John Doe', + username: 'johndoe', + webUrl: '/link', + avatarUrl: '/avatar', +}; + +describe('Sidebar participant component', () => { + let wrapper; + + const findAvatar = () => wrapper.findComponent(GlAvatarLabeled); + + const createComponent = (status = null) => { + wrapper = shallowMount(SidebarParticipant, { + propsData: { + user: { + ...user, + status, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('when user is not busy', () => { + createComponent(); + + expect(findAvatar().props('label')).toBe(user.name); + }); + + it('when user is busy', () => { + createComponent({ availability: 'BUSY' }); + + expect(findAvatar().props('label')).toBe(`${user.name} (Busy)`); + }); +}); diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js index d5e6310ed38..28a19fb9df6 100644 --- a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js +++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js @@ -20,11 +20,9 @@ describe('Sidebar Confidentiality Form', () => { mutate = jest.fn().mockResolvedValue('Success'), } = {}) => { wrapper = shallowMount(SidebarConfidentialityForm, { - provide: { + propsData: { fullPath: 'group/project', iid: '1', - }, - propsData: { confidential: false, issuableType: 'issue', ...props, diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js index 20a5be9b518..707215d0739 100644 --- a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js +++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js @@ -35,11 +35,11 @@ describe('Sidebar Confidentiality Widget', () => { localVue, apolloProvider: fakeApollo, provide: { - fullPath: 'group/project', - iid: '1', canUpdate: true, }, propsData: { + fullPath: 'group/project', + iid: '1', issuableType: 'issue', }, stubs: { diff --git a/spec/frontend/sidebar/components/copy_email_to_clipboard_spec.js b/spec/frontend/sidebar/components/copy_email_to_clipboard_spec.js index 704847f65bf..699b2bbd0b1 100644 --- a/spec/frontend/sidebar/components/copy_email_to_clipboard_spec.js +++ b/spec/frontend/sidebar/components/copy_email_to_clipboard_spec.js @@ -1,22 +1,17 @@ -import { getByText } from '@testing-library/dom'; -import { mount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import CopyEmailToClipboard from '~/sidebar/components/copy_email_to_clipboard.vue'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue'; describe('CopyEmailToClipboard component', () => { - const sampleEmail = 'sample+email@test.com'; + const mockIssueEmailAddress = 'sample+email@test.com'; - const wrapper = mount(CopyEmailToClipboard, { + const wrapper = shallowMount(CopyEmailToClipboard, { propsData: { - copyText: sampleEmail, + issueEmailAddress: mockIssueEmailAddress, }, }); - it('renders the Issue email text with the forwardable email', () => { - expect(getByText(wrapper.element, `Issue email: ${sampleEmail}`)).not.toBeNull(); - }); - - it('finds ClipboardButton with the correct props', () => { - expect(wrapper.find(ClipboardButton).props('text')).toBe(sampleEmail); + it('sets CopyableField `value` prop to issueEmailAddress', () => { + expect(wrapper.find(CopyableField).props('value')).toBe(mockIssueEmailAddress); }); }); diff --git a/spec/frontend/sidebar/components/due_date/sidebar_due_date_widget_spec.js b/spec/frontend/sidebar/components/due_date/sidebar_due_date_widget_spec.js new file mode 100644 index 00000000000..f58ceb0f1be --- /dev/null +++ b/spec/frontend/sidebar/components/due_date/sidebar_due_date_widget_spec.js @@ -0,0 +1,106 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import SidebarDueDateWidget from '~/sidebar/components/due_date/sidebar_due_date_widget.vue'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql'; +import { issueDueDateResponse } from '../../mock_data'; + +jest.mock('~/flash'); + +Vue.use(VueApollo); + +describe('Sidebar Due date Widget', () => { + let wrapper; + let fakeApollo; + const date = '2021-04-15'; + + const findEditableItem = () => wrapper.findComponent(SidebarEditableItem); + const findFormattedDueDate = () => wrapper.find("[data-testid='sidebar-duedate-value']"); + + const createComponent = ({ + dueDateQueryHandler = jest.fn().mockResolvedValue(issueDueDateResponse()), + } = {}) => { + fakeApollo = createMockApollo([[issueDueDateQuery, dueDateQueryHandler]]); + + wrapper = shallowMount(SidebarDueDateWidget, { + apolloProvider: fakeApollo, + provide: { + fullPath: 'group/project', + iid: '1', + canUpdate: true, + }, + propsData: { + issuableType: 'issue', + }, + stubs: { + SidebarEditableItem, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + it('passes a `loading` prop as true to editable item when query is loading', () => { + createComponent(); + + expect(findEditableItem().props('loading')).toBe(true); + }); + + describe('when issue has no due date', () => { + beforeEach(async () => { + createComponent({ + dueDateQueryHandler: jest.fn().mockResolvedValue(issueDueDateResponse(null)), + }); + await waitForPromises(); + }); + + it('passes a `loading` prop as false to editable item', () => { + expect(findEditableItem().props('loading')).toBe(false); + }); + + it('dueDate is null by default', () => { + expect(findFormattedDueDate().text()).toBe('None'); + }); + + it('emits `dueDateUpdated` event with a `null` payload', () => { + expect(wrapper.emitted('dueDateUpdated')).toEqual([[null]]); + }); + }); + + describe('when issue has due date', () => { + beforeEach(async () => { + createComponent({ + dueDateQueryHandler: jest.fn().mockResolvedValue(issueDueDateResponse(date)), + }); + await waitForPromises(); + }); + + it('passes a `loading` prop as false to editable item', () => { + expect(findEditableItem().props('loading')).toBe(false); + }); + + it('has dueDate', () => { + expect(findFormattedDueDate().text()).toBe('Apr 15, 2021'); + }); + + it('emits `dueDateUpdated` event with the date payload', () => { + expect(wrapper.emitted('dueDateUpdated')).toEqual([[date]]); + }); + }); + + it('displays a flash message when query is rejected', async () => { + createComponent({ + dueDateQueryHandler: jest.fn().mockRejectedValue('Houston, we have a problem'), + }); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js b/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js index 1dbb7702a15..cc428693930 100644 --- a/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js +++ b/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js @@ -1,4 +1,3 @@ -import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; @@ -8,18 +7,21 @@ import { IssuableType } from '~/issue_show/constants'; import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue'; import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql'; import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue'; import { issueReferenceResponse } from '../../mock_data'; describe('Sidebar Reference Widget', () => { let wrapper; let fakeApollo; - const referenceText = 'reference'; + + const mockReferenceValue = 'reference-1234'; + + const findCopyableField = () => wrapper.findComponent(CopyableField); const createComponent = ({ - issuableType, + issuableType = IssuableType.Issue, referenceQuery = issueReferenceQuery, - referenceQueryHandler = jest.fn().mockResolvedValue(issueReferenceResponse(referenceText)), + referenceQueryHandler = jest.fn().mockResolvedValue(issueReferenceResponse(mockReferenceValue)), } = {}) => { Vue.use(VueApollo); @@ -39,14 +41,20 @@ describe('Sidebar Reference Widget', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; + }); + + describe('when reference is loading', () => { + it('sets CopyableField `is-loading` prop to `true`', () => { + createComponent({ referenceQueryHandler: jest.fn().mockReturnValue(new Promise(() => {})) }); + expect(findCopyableField().props('isLoading')).toBe(true); + }); }); describe.each([ [IssuableType.Issue, issueReferenceQuery], [IssuableType.MergeRequest, mergeRequestReferenceQuery], ])('when issuableType is %s', (issuableType, referenceQuery) => { - it('displays the reference text', async () => { + it('sets CopyableField `value` prop to reference value', async () => { createComponent({ issuableType, referenceQuery, @@ -54,40 +62,32 @@ describe('Sidebar Reference Widget', () => { await waitForPromises(); - expect(wrapper.text()).toContain(referenceText); + expect(findCopyableField().props('value')).toBe(mockReferenceValue); }); - it('displays loading icon while fetching and hides clipboard icon', async () => { - createComponent({ - issuableType, - referenceQuery, - }); + describe('when error occurs', () => { + it('calls createFlash with correct parameters', async () => { + const mockError = new Error('mayday'); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); - expect(wrapper.find(ClipboardButton).exists()).toBe(false); - }); + createComponent({ + issuableType, + referenceQuery, + referenceQueryHandler: jest.fn().mockRejectedValue(mockError), + }); - it('calls createFlash with correct parameters', async () => { - const mockError = new Error('mayday'); + await waitForPromises(); - createComponent({ - issuableType, - referenceQuery, - referenceQueryHandler: jest.fn().mockRejectedValue(mockError), + const [ + [ + { + message, + error: { networkError }, + }, + ], + ] = wrapper.emitted('fetch-error'); + expect(message).toBe('An error occurred while fetching reference'); + expect(networkError).toEqual(mockError); }); - - await waitForPromises(); - - const [ - [ - { - message, - error: { networkError }, - }, - ], - ] = wrapper.emitted('fetch-error'); - expect(message).toBe('An error occurred while fetching reference'); - expect(networkError).toEqual(mockError); }); }); }); diff --git a/spec/frontend/sidebar/issuable_assignees_spec.js b/spec/frontend/sidebar/issuable_assignees_spec.js index af4dc315aad..3563d478f3f 100644 --- a/spec/frontend/sidebar/issuable_assignees_spec.js +++ b/spec/frontend/sidebar/issuable_assignees_spec.js @@ -5,12 +5,15 @@ import UncollapsedAssigneeList from '~/sidebar/components/assignees/uncollapsed_ describe('IssuableAssignees', () => { let wrapper; - const createComponent = (props = { users: [] }) => { + const createComponent = (props = {}) => { wrapper = shallowMount(IssuableAssignees, { provide: { rootPath: '', }, - propsData: { ...props }, + propsData: { + users: [], + ...props, + }, }); }; const findUncollapsedAssigneeList = () => wrapper.find(UncollapsedAssigneeList); @@ -22,12 +25,14 @@ describe('IssuableAssignees', () => { }); describe('when no assignees are present', () => { - beforeEach(() => { - createComponent(); + it('renders "None - assign yourself" when user is logged in', () => { + createComponent({ signedIn: true }); + expect(findEmptyAssignee().text()).toBe('None - assign yourself'); }); - it('renders "None - assign yourself"', () => { - expect(findEmptyAssignee().text()).toBe('None - assign yourself'); + it('renders "None" when user is not logged in', () => { + createComponent(); + expect(findEmptyAssignee().text()).toBe('None'); }); }); @@ -41,7 +46,7 @@ describe('IssuableAssignees', () => { describe('when clicking "assign yourself"', () => { it('emits "assign-self"', () => { - createComponent(); + createComponent({ signedIn: true }); wrapper.find('[data-testid="assign-yourself"]').vm.$emit('click'); expect(wrapper.emitted('assign-self')).toHaveLength(1); }); diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index e751f1239c8..2a4858a6320 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -233,6 +233,19 @@ export const issueConfidentialityResponse = (confidential = false) => ({ }, }); +export const issueDueDateResponse = (dueDate = null) => ({ + data: { + workspace: { + __typename: 'Project', + issuable: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/4', + dueDate, + }, + }, + }, +}); + export const issueReferenceResponse = (reference) => ({ data: { workspace: { @@ -245,4 +258,147 @@ export const issueReferenceResponse = (reference) => ({ }, }, }); + +export const issuableQueryResponse = { + data: { + workspace: { + __typename: 'Project', + issuable: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/1', + iid: '1', + participants: { + nodes: [ + { + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: '/root', + status: null, + }, + { + id: 'gid://gitlab/User/2', + avatarUrl: + 'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon', + name: 'Jacki Kub', + username: 'francina.skiles', + webUrl: '/franc', + status: { + availability: 'BUSY', + }, + }, + { + id: 'gid://gitlab/User/3', + avatarUrl: '/avatar', + name: 'John Doe', + username: 'johndoe', + webUrl: '/john', + status: null, + }, + ], + }, + assignees: { + nodes: [ + { + id: 'gid://gitlab/User/2', + avatarUrl: + 'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon', + name: 'Jacki Kub', + username: 'francina.skiles', + webUrl: '/franc', + status: null, + }, + ], + }, + }, + }, + }, +}; + +export const searchQueryResponse = { + data: { + workspace: { + __typename: 'Project', + users: { + nodes: [ + { + user: { + id: '1', + avatarUrl: '/avatar', + name: 'root', + username: 'root', + webUrl: 'root', + status: null, + }, + }, + { + user: { + id: '2', + avatarUrl: '/avatar2', + name: 'rookie', + username: 'rookie', + webUrl: 'rookie', + status: null, + }, + }, + ], + }, + }, + }, +}; + +export const updateIssueAssigneesMutationResponse = { + data: { + issuableSetAssignees: { + issuable: { + id: 'gid://gitlab/Issue/1', + iid: '1', + assignees: { + nodes: [ + { + __typename: 'User', + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: '/root', + status: null, + }, + ], + __typename: 'UserConnection', + }, + participants: { + nodes: [ + { + __typename: 'User', + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: '/root', + status: null, + }, + { + __typename: 'User', + id: 'gid://gitlab/User/2', + avatarUrl: + 'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon', + name: 'Jacki Kub', + username: 'francina.skiles', + webUrl: '/franc', + status: null, + }, + ], + __typename: 'UserConnection', + }, + __typename: 'Issue', + }, + }, + }, +}; + export default mockData; |