diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-12-17 11:59:07 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-12-17 11:59:07 +0000 |
commit | 8b573c94895dc0ac0e1d9d59cf3e8745e8b539ca (patch) | |
tree | 544930fb309b30317ae9797a9683768705d664c4 /spec/frontend/members | |
parent | 4b1de649d0168371549608993deac953eb692019 (diff) | |
download | gitlab-ce-8b573c94895dc0ac0e1d9d59cf3e8745e8b539ca.tar.gz |
Add latest changes from gitlab-org/gitlab@13-7-stable-eev13.7.0-rc42
Diffstat (limited to 'spec/frontend/members')
30 files changed, 3079 insertions, 0 deletions
diff --git a/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js new file mode 100644 index 00000000000..9a8434a1222 --- /dev/null +++ b/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js @@ -0,0 +1,108 @@ +import { shallowMount } from '@vue/test-utils'; +import AccessRequestActionButtons from '~/members/components/action_buttons/access_request_action_buttons.vue'; +import RemoveMemberButton from '~/members/components/action_buttons/remove_member_button.vue'; +import ApproveAccessRequestButton from '~/members/components/action_buttons/approve_access_request_button.vue'; +import { accessRequest as member } from '../../mock_data'; + +describe('AccessRequestActionButtons', () => { + let wrapper; + + const createComponent = (propsData = {}) => { + wrapper = shallowMount(AccessRequestActionButtons, { + propsData: { + member, + isCurrentUser: true, + ...propsData, + }, + }); + }; + + const findRemoveMemberButton = () => wrapper.find(RemoveMemberButton); + const findApproveButton = () => wrapper.find(ApproveAccessRequestButton); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when user has `canRemove` permissions', () => { + beforeEach(() => { + createComponent({ + permissions: { + canRemove: true, + }, + }); + }); + + it('renders remove member button', () => { + expect(findRemoveMemberButton().exists()).toBe(true); + }); + + it('sets props correctly', () => { + expect(findRemoveMemberButton().props()).toMatchObject({ + memberId: member.id, + title: 'Deny access', + isAccessRequest: true, + icon: 'close', + }); + }); + + describe('when member is the current user', () => { + it('sets `message` prop correctly', () => { + expect(findRemoveMemberButton().props('message')).toBe( + `Are you sure you want to withdraw your access request for "${member.source.name}"`, + ); + }); + }); + + describe('when member is not the current user', () => { + it('sets `message` prop correctly', () => { + createComponent({ + isCurrentUser: false, + permissions: { + canRemove: true, + }, + }); + + expect(findRemoveMemberButton().props('message')).toBe( + `Are you sure you want to deny ${member.user.name}'s request to join "${member.source.name}"`, + ); + }); + }); + }); + + describe('when user does not have `canRemove` permissions', () => { + it('does not render remove member button', () => { + createComponent({ + permissions: { + canRemove: false, + }, + }); + + expect(findRemoveMemberButton().exists()).toBe(false); + }); + }); + + describe('when user has `canUpdate` permissions', () => { + it('renders the approve button', () => { + createComponent({ + permissions: { + canUpdate: true, + }, + }); + + expect(findApproveButton().exists()).toBe(true); + }); + }); + + describe('when user does not have `canUpdate` permissions', () => { + it('does not render the approve button', () => { + createComponent({ + permissions: { + canUpdate: false, + }, + }); + + expect(findApproveButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/members/components/action_buttons/approve_access_request_button_spec.js b/spec/frontend/members/components/action_buttons/approve_access_request_button_spec.js new file mode 100644 index 00000000000..7ce2c633bb3 --- /dev/null +++ b/spec/frontend/members/components/action_buttons/approve_access_request_button_spec.js @@ -0,0 +1,74 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlButton, GlForm } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import ApproveAccessRequestButton from '~/members/components/action_buttons/approve_access_request_button.vue'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ApproveAccessRequestButton', () => { + let wrapper; + + const createStore = (state = {}) => { + return new Vuex.Store({ + state: { + memberPath: '/groups/foo-bar/-/group_members/:id', + ...state, + }, + }); + }; + + const createComponent = (propsData = {}, state) => { + wrapper = shallowMount(ApproveAccessRequestButton, { + localVue, + store: createStore(state), + propsData: { + memberId: 1, + ...propsData, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + const findForm = () => wrapper.find(GlForm); + const findButton = () => findForm().find(GlButton); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays a tooltip', () => { + const button = findButton(); + + expect(getBinding(button.element, 'gl-tooltip')).not.toBeUndefined(); + expect(button.attributes('title')).toBe('Grant access'); + }); + + it('sets `aria-label` attribute', () => { + expect(findButton().attributes('aria-label')).toBe('Grant access'); + }); + + it('submits the form when button is clicked', () => { + expect(findButton().attributes('type')).toBe('submit'); + }); + + it('displays form with correct action and inputs', () => { + const form = findForm(); + + expect(form.attributes('action')).toBe( + '/groups/foo-bar/-/group_members/1/approve_access_request', + ); + expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe( + 'mock-csrf-token', + ); + }); +}); diff --git a/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js new file mode 100644 index 00000000000..887b21dc1d0 --- /dev/null +++ b/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js @@ -0,0 +1,85 @@ +import { shallowMount } from '@vue/test-utils'; +import InviteActionButtons from '~/members/components/action_buttons/invite_action_buttons.vue'; +import RemoveMemberButton from '~/members/components/action_buttons/remove_member_button.vue'; +import ResendInviteButton from '~/members/components/action_buttons/resend_invite_button.vue'; +import { invite as member } from '../../mock_data'; + +describe('InviteActionButtons', () => { + let wrapper; + + const createComponent = (propsData = {}) => { + wrapper = shallowMount(InviteActionButtons, { + propsData: { + member, + ...propsData, + }, + }); + }; + + const findRemoveMemberButton = () => wrapper.find(RemoveMemberButton); + const findResendInviteButton = () => wrapper.find(ResendInviteButton); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when user has `canRemove` permissions', () => { + beforeEach(() => { + createComponent({ + permissions: { + canRemove: true, + }, + }); + }); + + it('renders remove member button', () => { + expect(findRemoveMemberButton().exists()).toBe(true); + }); + + it('sets props correctly', () => { + expect(findRemoveMemberButton().props()).toEqual({ + memberId: member.id, + message: `Are you sure you want to revoke the invitation for ${member.invite.email} to join "${member.source.name}"`, + title: 'Revoke invite', + isAccessRequest: false, + icon: 'remove', + }); + }); + }); + + describe('when user does not have `canRemove` permissions', () => { + it('does not render remove member button', () => { + createComponent({ + permissions: { + canRemove: false, + }, + }); + + expect(findRemoveMemberButton().exists()).toBe(false); + }); + }); + + describe('when user has `canResend` permissions', () => { + it('renders resend invite button', () => { + createComponent({ + permissions: { + canResend: true, + }, + }); + + expect(findResendInviteButton().exists()).toBe(true); + }); + }); + + describe('when user does not have `canResend` permissions', () => { + it('does not render resend invite button', () => { + createComponent({ + permissions: { + canResend: false, + }, + }); + + expect(findResendInviteButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/members/components/action_buttons/leave_button_spec.js b/spec/frontend/members/components/action_buttons/leave_button_spec.js new file mode 100644 index 00000000000..2afe112c74b --- /dev/null +++ b/spec/frontend/members/components/action_buttons/leave_button_spec.js @@ -0,0 +1,59 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import LeaveButton from '~/members/components/action_buttons/leave_button.vue'; +import LeaveModal from '~/members/components/modals/leave_modal.vue'; +import { LEAVE_MODAL_ID } from '~/members/constants'; +import { member } from '../../mock_data'; + +describe('LeaveButton', () => { + let wrapper; + + const createComponent = (propsData = {}) => { + wrapper = shallowMount(LeaveButton, { + propsData: { + member, + ...propsData, + }, + directives: { + GlTooltip: createMockDirective(), + GlModal: createMockDirective(), + }, + }); + }; + + const findButton = () => wrapper.find(GlButton); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays a tooltip', () => { + const button = findButton(); + + expect(getBinding(button.element, 'gl-tooltip')).not.toBeUndefined(); + expect(button.attributes('title')).toBe('Leave'); + }); + + it('sets `aria-label` attribute', () => { + expect(findButton().attributes('aria-label')).toBe('Leave'); + }); + + it('renders leave modal', () => { + const leaveModal = wrapper.find(LeaveModal); + + expect(leaveModal.exists()).toBe(true); + expect(leaveModal.props('member')).toEqual(member); + }); + + it('triggers leave modal', () => { + const binding = getBinding(findButton().element, 'gl-modal'); + + expect(binding).not.toBeUndefined(); + expect(binding.value).toBe(LEAVE_MODAL_ID); + }); +}); diff --git a/spec/frontend/members/components/action_buttons/remove_group_link_button_spec.js b/spec/frontend/members/components/action_buttons/remove_group_link_button_spec.js new file mode 100644 index 00000000000..45283788676 --- /dev/null +++ b/spec/frontend/members/components/action_buttons/remove_group_link_button_spec.js @@ -0,0 +1,64 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlButton } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import RemoveGroupLinkButton from '~/members/components/action_buttons/remove_group_link_button.vue'; +import { group } from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('RemoveGroupLinkButton', () => { + let wrapper; + + const actions = { + showRemoveGroupLinkModal: jest.fn(), + }; + + const createStore = () => { + return new Vuex.Store({ + actions, + }); + }; + + const createComponent = () => { + wrapper = mount(RemoveGroupLinkButton, { + localVue, + store: createStore(), + propsData: { + groupLink: group, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + const findButton = () => wrapper.find(GlButton); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('displays a tooltip', () => { + const button = findButton(); + + expect(getBinding(button.element, 'gl-tooltip')).not.toBeUndefined(); + expect(button.attributes('title')).toBe('Remove group'); + }); + + it('sets `aria-label` attribute', () => { + expect(findButton().attributes('aria-label')).toBe('Remove group'); + }); + + it('calls Vuex action to open remove group link modal when clicked', () => { + findButton().trigger('click'); + + expect(actions.showRemoveGroupLinkModal).toHaveBeenCalledWith(expect.any(Object), group); + }); +}); diff --git a/spec/frontend/members/components/action_buttons/remove_member_button_spec.js b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js new file mode 100644 index 00000000000..437b3e705a4 --- /dev/null +++ b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js @@ -0,0 +1,66 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import RemoveMemberButton from '~/members/components/action_buttons/remove_member_button.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('RemoveMemberButton', () => { + let wrapper; + + const createStore = (state = {}) => { + return new Vuex.Store({ + state: { + memberPath: '/groups/foo-bar/-/group_members/:id', + ...state, + }, + }); + }; + + const createComponent = (propsData = {}, state) => { + wrapper = shallowMount(RemoveMemberButton, { + localVue, + store: createStore(state), + propsData: { + memberId: 1, + message: 'Are you sure you want to remove John Smith?', + title: 'Remove member', + isAccessRequest: true, + ...propsData, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('sets attributes on button', () => { + createComponent(); + + expect(wrapper.attributes()).toMatchObject({ + 'data-member-path': '/groups/foo-bar/-/group_members/1', + 'data-message': 'Are you sure you want to remove John Smith?', + 'data-is-access-request': 'true', + 'aria-label': 'Remove member', + title: 'Remove member', + icon: 'remove', + }); + }); + + it('displays `title` prop as a tooltip', () => { + createComponent(); + + expect(getBinding(wrapper.element, 'gl-tooltip')).not.toBeUndefined(); + }); + + it('has CSS class used by `remove_member_modal.vue`', () => { + createComponent(); + + expect(wrapper.classes()).toContain('js-remove-member-button'); + }); +}); diff --git a/spec/frontend/members/components/action_buttons/resend_invite_button_spec.js b/spec/frontend/members/components/action_buttons/resend_invite_button_spec.js new file mode 100644 index 00000000000..a48942dd277 --- /dev/null +++ b/spec/frontend/members/components/action_buttons/resend_invite_button_spec.js @@ -0,0 +1,66 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlButton } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import ResendInviteButton from '~/members/components/action_buttons/resend_invite_button.vue'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ResendInviteButton', () => { + let wrapper; + + const createStore = (state = {}) => { + return new Vuex.Store({ + state: { + memberPath: '/groups/foo-bar/-/group_members/:id', + ...state, + }, + }); + }; + + const createComponent = (propsData = {}, state) => { + wrapper = shallowMount(ResendInviteButton, { + localVue, + store: createStore(state), + propsData: { + memberId: 1, + ...propsData, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + const findForm = () => wrapper.find('form'); + const findButton = () => findForm().find(GlButton); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays a tooltip', () => { + expect(getBinding(findButton().element, 'gl-tooltip')).not.toBeUndefined(); + expect(findButton().attributes('title')).toBe('Resend invite'); + }); + + it('submits the form when button is clicked', () => { + expect(findButton().attributes('type')).toBe('submit'); + }); + + it('displays form with correct action and inputs', () => { + expect(findForm().attributes('action')).toBe('/groups/foo-bar/-/group_members/1/resend_invite'); + expect( + findForm() + .find('input[name="authenticity_token"]') + .attributes('value'), + ).toBe('mock-csrf-token'); + }); +}); diff --git a/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js new file mode 100644 index 00000000000..b03e80a537d --- /dev/null +++ b/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js @@ -0,0 +1,89 @@ +import { shallowMount } from '@vue/test-utils'; +import UserActionButtons from '~/members/components/action_buttons/user_action_buttons.vue'; +import RemoveMemberButton from '~/members/components/action_buttons/remove_member_button.vue'; +import LeaveButton from '~/members/components/action_buttons/leave_button.vue'; +import { member, orphanedMember } from '../../mock_data'; + +describe('UserActionButtons', () => { + let wrapper; + + const createComponent = (propsData = {}) => { + wrapper = shallowMount(UserActionButtons, { + propsData: { + member, + isCurrentUser: false, + ...propsData, + }, + }); + }; + + const findRemoveMemberButton = () => wrapper.find(RemoveMemberButton); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when user has `canRemove` permissions', () => { + beforeEach(() => { + createComponent({ + permissions: { + canRemove: true, + }, + }); + }); + + it('renders remove member button', () => { + expect(findRemoveMemberButton().exists()).toBe(true); + }); + + it('sets props correctly', () => { + expect(findRemoveMemberButton().props()).toEqual({ + memberId: member.id, + message: `Are you sure you want to remove ${member.user.name} from "${member.source.name}"`, + title: 'Remove member', + isAccessRequest: false, + icon: 'remove', + }); + }); + + describe('when member is orphaned', () => { + it('sets `message` prop correctly', () => { + createComponent({ + member: orphanedMember, + permissions: { + canRemove: true, + }, + }); + + expect(findRemoveMemberButton().props('message')).toBe( + `Are you sure you want to remove this orphaned member from "${orphanedMember.source.name}"`, + ); + }); + }); + + describe('when member is the current user', () => { + it('renders leave button', () => { + createComponent({ + isCurrentUser: true, + permissions: { + canRemove: true, + }, + }); + + expect(wrapper.find(LeaveButton).exists()).toBe(true); + }); + }); + }); + + describe('when user does not have `canRemove` permissions', () => { + it('does not render remove member button', () => { + createComponent({ + permissions: { + canRemove: false, + }, + }); + + expect(findRemoveMemberButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/members/components/avatars/group_avatar_spec.js b/spec/frontend/members/components/avatars/group_avatar_spec.js new file mode 100644 index 00000000000..658bb9462b0 --- /dev/null +++ b/spec/frontend/members/components/avatars/group_avatar_spec.js @@ -0,0 +1,46 @@ +import { mount, createWrapper } from '@vue/test-utils'; +import { getByText as getByTextHelper } from '@testing-library/dom'; +import { GlAvatarLink } from '@gitlab/ui'; +import { group as member } from '../../mock_data'; +import GroupAvatar from '~/members/components/avatars/group_avatar.vue'; + +describe('MemberList', () => { + let wrapper; + + const group = member.sharedWithGroup; + + const createComponent = (propsData = {}) => { + wrapper = mount(GroupAvatar, { + propsData: { + member, + ...propsData, + }, + }); + }; + + const getByText = (text, options) => + createWrapper(getByTextHelper(wrapper.element, text, options)); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders link to group', () => { + const link = wrapper.find(GlAvatarLink); + + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe(group.webUrl); + }); + + it("renders group's full name", () => { + expect(getByText(group.fullName).exists()).toBe(true); + }); + + it("renders group's avatar", () => { + expect(wrapper.find('img').attributes('src')).toBe(group.avatarUrl); + }); +}); diff --git a/spec/frontend/members/components/avatars/invite_avatar_spec.js b/spec/frontend/members/components/avatars/invite_avatar_spec.js new file mode 100644 index 00000000000..13ee727528b --- /dev/null +++ b/spec/frontend/members/components/avatars/invite_avatar_spec.js @@ -0,0 +1,38 @@ +import { mount, createWrapper } from '@vue/test-utils'; +import { getByText as getByTextHelper } from '@testing-library/dom'; +import { invite as member } from '../../mock_data'; +import InviteAvatar from '~/members/components/avatars/invite_avatar.vue'; + +describe('MemberList', () => { + let wrapper; + + const { invite } = member; + + const createComponent = (propsData = {}) => { + wrapper = mount(InviteAvatar, { + propsData: { + member, + ...propsData, + }, + }); + }; + + const getByText = (text, options) => + createWrapper(getByTextHelper(wrapper.element, text, options)); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders email as name', () => { + expect(getByText(invite.email).exists()).toBe(true); + }); + + it('renders avatar', () => { + expect(wrapper.find('img').attributes('src')).toBe(invite.avatarUrl); + }); +}); diff --git a/spec/frontend/members/components/avatars/user_avatar_spec.js b/spec/frontend/members/components/avatars/user_avatar_spec.js new file mode 100644 index 00000000000..7d6a9065975 --- /dev/null +++ b/spec/frontend/members/components/avatars/user_avatar_spec.js @@ -0,0 +1,115 @@ +import { mount, createWrapper } from '@vue/test-utils'; +import { within } from '@testing-library/dom'; +import { GlAvatarLink, GlBadge } from '@gitlab/ui'; +import { member as memberMock, orphanedMember } from '../../mock_data'; +import UserAvatar from '~/members/components/avatars/user_avatar.vue'; + +describe('UserAvatar', () => { + let wrapper; + + const { user } = memberMock; + + const createComponent = (propsData = {}) => { + wrapper = mount(UserAvatar, { + propsData: { + member: memberMock, + isCurrentUser: false, + ...propsData, + }, + }); + }; + + const getByText = (text, options) => + createWrapper(within(wrapper.element).findByText(text, options)); + + const findStatusEmoji = emoji => wrapper.find(`gl-emoji[data-name="${emoji}"]`); + + afterEach(() => { + wrapper.destroy(); + }); + + it("renders link to user's profile", () => { + createComponent(); + + const link = wrapper.find(GlAvatarLink); + + expect(link.exists()).toBe(true); + expect(link.attributes()).toMatchObject({ + href: user.webUrl, + 'data-user-id': `${user.id}`, + 'data-username': user.username, + }); + }); + + it("renders user's name", () => { + createComponent(); + + expect(getByText(user.name).exists()).toBe(true); + }); + + it("renders user's username", () => { + createComponent(); + + expect(getByText(`@${user.username}`).exists()).toBe(true); + }); + + it("renders user's avatar", () => { + createComponent(); + + expect(wrapper.find('img').attributes('src')).toBe(user.avatarUrl); + }); + + describe('when user property does not exist', () => { + it('displays an orphaned user', () => { + createComponent({ member: orphanedMember }); + + expect(getByText('Orphaned member').exists()).toBe(true); + }); + }); + + describe('badges', () => { + it.each` + member | badgeText + ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${'Blocked'} + ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${'2FA'} + `('renders the "$badgeText" badge', ({ member, badgeText }) => { + createComponent({ member }); + + expect(wrapper.find(GlBadge).text()).toBe(badgeText); + }); + + it('renders the "It\'s you" badge when member is current user', () => { + createComponent({ isCurrentUser: true }); + + expect(getByText("It's you").exists()).toBe(true); + }); + }); + + describe('user status', () => { + const emoji = 'island'; + + describe('when set', () => { + it('displays the status emoji', () => { + createComponent({ + member: { + ...memberMock, + user: { + ...memberMock.user, + status: { emoji, messageHtml: 'On vacation' }, + }, + }, + }); + + expect(findStatusEmoji(emoji).exists()).toBe(true); + }); + }); + + describe('when not set', () => { + it('does not display status emoji', () => { + createComponent(); + + expect(findStatusEmoji(emoji).exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/members/components/filter_sort/filter_sort_container_spec.js b/spec/frontend/members/components/filter_sort/filter_sort_container_spec.js new file mode 100644 index 00000000000..91277ae6d03 --- /dev/null +++ b/spec/frontend/members/components/filter_sort/filter_sort_container_spec.js @@ -0,0 +1,68 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import FilterSortContainer from '~/members/components/filter_sort/filter_sort_container.vue'; +import MembersFilteredSearchBar from '~/members/components/filter_sort/members_filtered_search_bar.vue'; +import SortDropdown from '~/members/components/filter_sort/sort_dropdown.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('FilterSortContainer', () => { + let wrapper; + + const createComponent = state => { + const store = new Vuex.Store({ + state: { + filteredSearchBar: { + show: true, + tokens: ['two_factor'], + searchParam: 'search', + placeholder: 'Filter members', + recentSearchesStorageKey: 'group_members', + }, + tableSortableFields: ['account'], + ...state, + }, + }); + + wrapper = shallowMount(FilterSortContainer, { + localVue, + store, + }); + }; + + describe('when `filteredSearchBar.show` is `false` and `tableSortableFields` is empty', () => { + it('renders nothing', () => { + createComponent({ + filteredSearchBar: { + show: false, + }, + tableSortableFields: [], + }); + + expect(wrapper.html()).toBe(''); + }); + }); + + describe('when `filteredSearchBar.show` is `true`', () => { + it('renders `MembersFilteredSearchBar`', () => { + createComponent({ + filteredSearchBar: { + show: true, + }, + }); + + expect(wrapper.find(MembersFilteredSearchBar).exists()).toBe(true); + }); + }); + + describe('when `tableSortableFields` is set', () => { + it('renders `SortDropdown`', () => { + createComponent({ + tableSortableFields: ['account'], + }); + + expect(wrapper.find(SortDropdown).exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js new file mode 100644 index 00000000000..ca885000c2f --- /dev/null +++ b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js @@ -0,0 +1,176 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlFilteredSearchToken } from '@gitlab/ui'; +import MembersFilteredSearchBar from '~/members/components/filter_sort/members_filtered_search_bar.vue'; +import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('MembersFilteredSearchBar', () => { + let wrapper; + + const createComponent = state => { + const store = new Vuex.Store({ + state: { + sourceId: 1, + filteredSearchBar: { + show: true, + tokens: ['two_factor'], + searchParam: 'search', + placeholder: 'Filter members', + recentSearchesStorageKey: 'group_members', + }, + canManageMembers: true, + ...state, + }, + }); + + wrapper = shallowMount(MembersFilteredSearchBar, { + localVue, + store, + }); + }; + + const findFilteredSearchBar = () => wrapper.find(FilteredSearchBar); + + it('passes correct props to `FilteredSearchBar` component', () => { + createComponent(); + + expect(findFilteredSearchBar().props()).toMatchObject({ + namespace: '1', + recentSearchesStorageKey: 'group_members', + searchInputPlaceholder: 'Filter members', + }); + }); + + describe('filtering tokens', () => { + it('includes tokens set in `filteredSearchBar.tokens`', () => { + createComponent(); + + expect(findFilteredSearchBar().props('tokens')).toEqual([ + { + type: 'two_factor', + icon: 'lock', + title: '2FA', + token: GlFilteredSearchToken, + unique: true, + operators: [{ value: '=', description: 'is' }], + options: [ + { value: 'enabled', title: 'Enabled' }, + { value: 'disabled', title: 'Disabled' }, + ], + requiredPermissions: 'canManageMembers', + }, + ]); + }); + + describe('when `canManageMembers` is false', () => { + it('excludes 2FA token', () => { + createComponent({ + filteredSearchBar: { + show: true, + tokens: ['two_factor', 'with_inherited_permissions'], + searchParam: 'search', + placeholder: 'Filter members', + recentSearchesStorageKey: 'group_members', + }, + canManageMembers: false, + }); + + expect(findFilteredSearchBar().props('tokens')).toEqual([ + { + type: 'with_inherited_permissions', + icon: 'group', + title: 'Membership', + token: GlFilteredSearchToken, + unique: true, + operators: [{ value: '=', description: 'is' }], + options: [{ value: 'exclude', title: 'Direct' }, { value: 'only', title: 'Inherited' }], + }, + ]); + }); + }); + }); + + describe('when filters are set via query params', () => { + beforeEach(() => { + delete window.location; + window.location = new URL('https://localhost'); + }); + + it('parses and passes tokens to `FilteredSearchBar` component as `initialFilterValue` prop', () => { + window.location.search = '?two_factor=enabled&token_not_available=foobar'; + + createComponent(); + + expect(findFilteredSearchBar().props('initialFilterValue')).toEqual([ + { + type: 'two_factor', + value: { + data: 'enabled', + operator: '=', + }, + }, + ]); + }); + + it('parses and passes search param to `FilteredSearchBar` component as `initialFilterValue` prop', () => { + window.location.search = '?search=foobar'; + + createComponent(); + + expect(findFilteredSearchBar().props('initialFilterValue')).toEqual([ + { + type: 'filtered-search-term', + value: { + data: 'foobar', + }, + }, + ]); + }); + }); + + describe('when filter bar is submitted', () => { + beforeEach(() => { + delete window.location; + window.location = new URL('https://localhost'); + }); + + it('adds correct filter query params', () => { + createComponent(); + + findFilteredSearchBar().vm.$emit('onFilter', [ + { type: 'two_factor', value: { data: 'enabled', operator: '=' } }, + ]); + + expect(window.location.href).toBe('https://localhost/?two_factor=enabled'); + }); + + it('adds search query param', () => { + createComponent(); + + findFilteredSearchBar().vm.$emit('onFilter', [ + { type: 'two_factor', value: { data: 'enabled', operator: '=' } }, + { type: 'filtered-search-term', value: { data: 'foobar' } }, + ]); + + expect(window.location.href).toBe('https://localhost/?two_factor=enabled&search=foobar'); + }); + + it('adds sort query param', () => { + window.location.search = '?sort=name_asc'; + + createComponent(); + + findFilteredSearchBar().vm.$emit('onFilter', [ + { type: 'two_factor', value: { data: 'enabled', operator: '=' } }, + { type: 'filtered-search-term', value: { data: 'foobar' } }, + ]); + + expect(window.location.href).toBe( + 'https://localhost/?two_factor=enabled&search=foobar&sort=name_asc', + ); + }); + }); +}); diff --git a/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js b/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js new file mode 100644 index 00000000000..6fe67aded3d --- /dev/null +++ b/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js @@ -0,0 +1,162 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlSorting, GlSortingItem } from '@gitlab/ui'; +import SortDropdown from '~/members/components/filter_sort/sort_dropdown.vue'; +import * as urlUtilities from '~/lib/utils/url_utility'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('SortDropdown', () => { + let wrapper; + + const URL_HOST = 'https://localhost/'; + + const createComponent = state => { + const store = new Vuex.Store({ + state: { + sourceId: 1, + tableSortableFields: ['account', 'granted', 'expires', 'maxRole', 'lastSignIn'], + filteredSearchBar: { + show: true, + tokens: ['two_factor'], + searchParam: 'search', + placeholder: 'Filter members', + recentSearchesStorageKey: 'group_members', + }, + ...state, + }, + }); + + wrapper = mount(SortDropdown, { + localVue, + store, + }); + }; + + const findSortingComponent = () => wrapper.find(GlSorting); + const findSortDirectionToggle = () => + findSortingComponent().find('button[title="Sort direction"]'); + const findDropdownToggle = () => wrapper.find('button[aria-haspopup="true"]'); + const findDropdownItemByText = text => + wrapper + .findAll(GlSortingItem) + .wrappers.find(dropdownItemWrapper => dropdownItemWrapper.text() === text); + + describe('dropdown options', () => { + beforeEach(() => { + delete window.location; + window.location = new URL(URL_HOST); + }); + + it('adds dropdown items for all the sortable fields', () => { + const URL_FILTER_PARAMS = '?two_factor=enabled&search=foobar'; + const EXPECTED_BASE_URL = `${URL_HOST}${URL_FILTER_PARAMS}&sort=`; + + window.location.search = URL_FILTER_PARAMS; + + const expectedDropdownItems = [ + { + label: 'Account', + url: `${EXPECTED_BASE_URL}name_asc`, + }, + { + label: 'Access granted', + url: `${EXPECTED_BASE_URL}last_joined`, + }, + { + label: 'Max role', + url: `${EXPECTED_BASE_URL}access_level_asc`, + }, + { + label: 'Last sign-in', + url: `${EXPECTED_BASE_URL}recent_sign_in`, + }, + ]; + + createComponent(); + + expectedDropdownItems.forEach(expectedDropdownItem => { + const dropdownItem = findDropdownItemByText(expectedDropdownItem.label); + + expect(dropdownItem).not.toBe(null); + expect(dropdownItem.find('a').attributes('href')).toBe(expectedDropdownItem.url); + }); + }); + + it('checks selected sort option', () => { + window.location.search = '?sort=access_level_asc'; + + createComponent(); + + expect(findDropdownItemByText('Max role').vm.$attrs.active).toBe(true); + }); + }); + + describe('dropdown toggle', () => { + beforeEach(() => { + delete window.location; + window.location = new URL(URL_HOST); + }); + + it('defaults to sorting by "Account" in ascending order', () => { + createComponent(); + + expect(findSortingComponent().props('isAscending')).toBe(true); + expect(findDropdownToggle().text()).toBe('Account'); + }); + + it('sets text as selected sort option', () => { + window.location.search = '?sort=access_level_asc'; + + createComponent(); + + expect(findDropdownToggle().text()).toBe('Max role'); + }); + }); + + describe('sort direction toggle', () => { + beforeEach(() => { + delete window.location; + window.location = new URL(URL_HOST); + + jest.spyOn(urlUtilities, 'visitUrl'); + }); + + describe('when current sort direction is ascending', () => { + beforeEach(() => { + window.location.search = '?sort=access_level_asc'; + + createComponent(); + }); + + describe('when sort direction toggle is clicked', () => { + beforeEach(() => { + findSortDirectionToggle().trigger('click'); + }); + + it('sorts in descending order', () => { + expect(urlUtilities.visitUrl).toHaveBeenCalledWith(`${URL_HOST}?sort=access_level_desc`); + }); + }); + }); + + describe('when current sort direction is descending', () => { + beforeEach(() => { + window.location.search = '?sort=access_level_desc'; + + createComponent(); + }); + + describe('when sort direction toggle is clicked', () => { + beforeEach(() => { + findSortDirectionToggle().trigger('click'); + }); + + it('sorts in ascending order', () => { + expect(urlUtilities.visitUrl).toHaveBeenCalledWith(`${URL_HOST}?sort=access_level_asc`); + }); + }); + }); + }); +}); diff --git a/spec/frontend/members/components/modals/leave_modal_spec.js b/spec/frontend/members/components/modals/leave_modal_spec.js new file mode 100644 index 00000000000..d7acf12212c --- /dev/null +++ b/spec/frontend/members/components/modals/leave_modal_spec.js @@ -0,0 +1,91 @@ +import { mount, createLocalVue, createWrapper } from '@vue/test-utils'; +import { GlModal, GlForm } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { within } from '@testing-library/dom'; +import Vuex from 'vuex'; +import LeaveModal from '~/members/components/modals/leave_modal.vue'; +import { LEAVE_MODAL_ID } from '~/members/constants'; +import { member } from '../../mock_data'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('LeaveModal', () => { + let wrapper; + + const createStore = (state = {}) => { + return new Vuex.Store({ + state: { + memberPath: '/groups/foo-bar/-/group_members/:id', + ...state, + }, + }); + }; + + const createComponent = (propsData = {}, state) => { + wrapper = mount(LeaveModal, { + localVue, + store: createStore(state), + propsData: { + member, + ...propsData, + }, + attrs: { + static: true, + visible: true, + }, + }); + }; + + const findModal = () => wrapper.find(GlModal); + + const findForm = () => findModal().find(GlForm); + + const getByText = (text, options) => + createWrapper(within(findModal().element).getByText(text, options)); + + beforeEach(async () => { + createComponent(); + await nextTick(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('sets modal ID', () => { + expect(findModal().props('modalId')).toBe(LEAVE_MODAL_ID); + }); + + it('displays modal title', () => { + expect(getByText(`Leave "${member.source.name}"`).exists()).toBe(true); + }); + + it('displays modal body', () => { + expect(getByText(`Are you sure you want to leave "${member.source.name}"?`).exists()).toBe( + true, + ); + }); + + it('displays form with correct action and inputs', () => { + const form = findForm(); + + expect(form.attributes('action')).toBe('/groups/foo-bar/-/group_members/leave'); + expect(form.find('input[name="_method"]').attributes('value')).toBe('delete'); + expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe( + 'mock-csrf-token', + ); + }); + + it('submits the form when "Leave" button is clicked', () => { + const submitSpy = jest.spyOn(findForm().element, 'submit'); + + getByText('Leave').trigger('click'); + + expect(submitSpy).toHaveBeenCalled(); + + submitSpy.mockRestore(); + }); +}); diff --git a/spec/frontend/members/components/modals/remove_group_link_modal_spec.js b/spec/frontend/members/components/modals/remove_group_link_modal_spec.js new file mode 100644 index 00000000000..593dbcd28ba --- /dev/null +++ b/spec/frontend/members/components/modals/remove_group_link_modal_spec.js @@ -0,0 +1,106 @@ +import { mount, createLocalVue, createWrapper } from '@vue/test-utils'; +import { GlModal, GlForm } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { within } from '@testing-library/dom'; +import Vuex from 'vuex'; +import RemoveGroupLinkModal from '~/members/components/modals/remove_group_link_modal.vue'; +import { REMOVE_GROUP_LINK_MODAL_ID } from '~/members/constants'; +import { group } from '../../mock_data'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('RemoveGroupLinkModal', () => { + let wrapper; + + const actions = { + hideRemoveGroupLinkModal: jest.fn(), + }; + + const createStore = (state = {}) => { + return new Vuex.Store({ + state: { + memberPath: '/groups/foo-bar/-/group_links/:id', + groupLinkToRemove: group, + removeGroupLinkModalVisible: true, + ...state, + }, + actions, + }); + }; + + const createComponent = state => { + wrapper = mount(RemoveGroupLinkModal, { + localVue, + store: createStore(state), + attrs: { + static: true, + }, + }); + }; + + const findModal = () => wrapper.find(GlModal); + const findForm = () => findModal().find(GlForm); + const getByText = (text, options) => + createWrapper(within(findModal().element).getByText(text, options)); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when modal is open', () => { + beforeEach(async () => { + createComponent(); + await nextTick(); + }); + + it('sets modal ID', () => { + expect(findModal().props('modalId')).toBe(REMOVE_GROUP_LINK_MODAL_ID); + }); + + it('displays modal title', () => { + expect(getByText(`Remove "${group.sharedWithGroup.fullName}"`).exists()).toBe(true); + }); + + it('displays modal body', () => { + expect( + getByText(`Are you sure you want to remove "${group.sharedWithGroup.fullName}"?`).exists(), + ).toBe(true); + }); + + it('displays form with correct action and inputs', () => { + const form = findForm(); + + expect(form.attributes('action')).toBe(`/groups/foo-bar/-/group_links/${group.id}`); + expect(form.find('input[name="_method"]').attributes('value')).toBe('delete'); + expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe( + 'mock-csrf-token', + ); + }); + + it('submits the form when "Remove group" button is clicked', () => { + const submitSpy = jest.spyOn(findForm().element, 'submit'); + + getByText('Remove group').trigger('click'); + + expect(submitSpy).toHaveBeenCalled(); + + submitSpy.mockRestore(); + }); + + it('calls `hideRemoveGroupLinkModal` action when modal is closed', () => { + getByText('Cancel').trigger('click'); + + expect(actions.hideRemoveGroupLinkModal).toHaveBeenCalled(); + }); + }); + + it('modal does not show when `removeGroupLinkModalVisible` is `false`', () => { + createComponent({ removeGroupLinkModalVisible: false }); + + expect(findModal().vm.$attrs.visible).toBe(false); + }); +}); diff --git a/spec/frontend/members/components/table/created_at_spec.js b/spec/frontend/members/components/table/created_at_spec.js new file mode 100644 index 00000000000..a9f809cd805 --- /dev/null +++ b/spec/frontend/members/components/table/created_at_spec.js @@ -0,0 +1,61 @@ +import { mount, createWrapper } from '@vue/test-utils'; +import { within } from '@testing-library/dom'; +import { useFakeDate } from 'helpers/fake_date'; +import CreatedAt from '~/members/components/table/created_at.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +describe('CreatedAt', () => { + // March 15th, 2020 + useFakeDate(2020, 2, 15); + + const date = '2020-03-01T00:00:00.000'; + const dateTimeAgo = '2 weeks ago'; + + let wrapper; + + const createComponent = propsData => { + wrapper = mount(CreatedAt, { + propsData: { + date, + ...propsData, + }, + }); + }; + + const getByText = (text, options) => + createWrapper(within(wrapper.element).getByText(text, options)); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('created at text', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays created at text', () => { + expect(getByText(dateTimeAgo).exists()).toBe(true); + }); + + it('uses `TimeAgoTooltip` component to display tooltip', () => { + expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true); + }); + }); + + describe('when `createdBy` prop is provided', () => { + it('displays a link to the user that created the member', () => { + createComponent({ + createdBy: { + name: 'Administrator', + webUrl: 'https://gitlab.com/root', + }, + }); + + const link = getByText('Administrator'); + + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe('https://gitlab.com/root'); + }); + }); +}); diff --git a/spec/frontend/members/components/table/expiration_datepicker_spec.js b/spec/frontend/members/components/table/expiration_datepicker_spec.js new file mode 100644 index 00000000000..ba1b2256e76 --- /dev/null +++ b/spec/frontend/members/components/table/expiration_datepicker_spec.js @@ -0,0 +1,166 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { nextTick } from 'vue'; +import { GlDatepicker } from '@gitlab/ui'; +import { useFakeDate } from 'helpers/fake_date'; +import waitForPromises from 'helpers/wait_for_promises'; +import ExpirationDatepicker from '~/members/components/table/expiration_datepicker.vue'; +import { member } from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ExpirationDatepicker', () => { + // March 15th, 2020 3:00 + useFakeDate(2020, 2, 15, 3); + + let wrapper; + let actions; + let resolveUpdateMemberExpiration; + const $toast = { + show: jest.fn(), + }; + + const createStore = () => { + actions = { + updateMemberExpiration: jest.fn( + () => + new Promise(resolve => { + resolveUpdateMemberExpiration = resolve; + }), + ), + }; + + return new Vuex.Store({ actions }); + }; + + const createComponent = (propsData = {}) => { + wrapper = mount(ExpirationDatepicker, { + propsData: { + member, + permissions: { canUpdate: true }, + ...propsData, + }, + localVue, + store: createStore(), + mocks: { + $toast, + }, + }); + }; + + const findInput = () => wrapper.find('input'); + const findDatepicker = () => wrapper.find(GlDatepicker); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('datepicker input', () => { + it('sets `member.expiresAt` as initial date', async () => { + createComponent({ member: { ...member, expiresAt: '2020-03-17T00:00:00Z' } }); + + await nextTick(); + + expect(findInput().element.value).toBe('2020-03-17'); + }); + }); + + describe('props', () => { + beforeEach(() => { + createComponent(); + }); + + it('sets `minDate` prop as tomorrow', () => { + expect( + findDatepicker() + .props('minDate') + .toISOString(), + ).toBe(new Date('2020-3-16').toISOString()); + }); + + it('sets `target` prop as `null` so datepicker opens on focus', () => { + expect(findDatepicker().props('target')).toBe(null); + }); + + it("sets `container` prop as `null` so table styles don't affect the datepicker styles", () => { + expect(findDatepicker().props('container')).toBe(null); + }); + + it('shows clear button', () => { + expect(findDatepicker().props('showClearButton')).toBe(true); + }); + }); + + describe('when datepicker is changed', () => { + beforeEach(async () => { + createComponent(); + + findDatepicker().vm.$emit('input', new Date('2020-03-17')); + }); + + it('calls `updateMemberExpiration` Vuex action', () => { + expect(actions.updateMemberExpiration).toHaveBeenCalledWith(expect.any(Object), { + memberId: member.id, + expiresAt: new Date('2020-03-17'), + }); + }); + + it('displays toast when successful', async () => { + resolveUpdateMemberExpiration(); + await waitForPromises(); + + expect($toast.show).toHaveBeenCalledWith('Expiration date updated successfully.'); + }); + + it('disables dropdown while waiting for `updateMemberExpiration` to resolve', async () => { + expect(findDatepicker().props('disabled')).toBe(true); + + resolveUpdateMemberExpiration(); + await waitForPromises(); + + expect(findDatepicker().props('disabled')).toBe(false); + }); + }); + + describe('when datepicker is cleared', () => { + beforeEach(async () => { + createComponent(); + + findInput().setValue('2020-03-17'); + await nextTick(); + wrapper.find('[data-testid="clear-button"]').trigger('click'); + }); + + it('calls `updateMemberExpiration` Vuex action', () => { + expect(actions.updateMemberExpiration).toHaveBeenCalledWith(expect.any(Object), { + memberId: member.id, + expiresAt: null, + }); + }); + + it('displays toast when successful', async () => { + resolveUpdateMemberExpiration(); + await waitForPromises(); + + expect($toast.show).toHaveBeenCalledWith('Expiration date removed successfully.'); + }); + + it('disables datepicker while waiting for `updateMemberExpiration` to resolve', async () => { + expect(findDatepicker().props('disabled')).toBe(true); + + resolveUpdateMemberExpiration(); + await waitForPromises(); + + expect(findDatepicker().props('disabled')).toBe(false); + }); + }); + + describe('when user does not have `canUpdate` permissions', () => { + it('disables datepicker', () => { + createComponent({ permissions: { canUpdate: false } }); + + expect(findDatepicker().props('disabled')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/members/components/table/expires_at_spec.js b/spec/frontend/members/components/table/expires_at_spec.js new file mode 100644 index 00000000000..cf0fc78656e --- /dev/null +++ b/spec/frontend/members/components/table/expires_at_spec.js @@ -0,0 +1,86 @@ +import { mount, createWrapper } from '@vue/test-utils'; +import { within } from '@testing-library/dom'; +import { useFakeDate } from 'helpers/fake_date'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import ExpiresAt from '~/members/components/table/expires_at.vue'; + +describe('ExpiresAt', () => { + // March 15th, 2020 + useFakeDate(2020, 2, 15); + + let wrapper; + + const createComponent = propsData => { + wrapper = mount(ExpiresAt, { + propsData, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + const getByText = (text, options) => + createWrapper(within(wrapper.element).getByText(text, options)); + + const getTooltipDirective = elementWrapper => getBinding(elementWrapper.element, 'gl-tooltip'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when no expiration date is set', () => { + it('displays "No expiration set"', () => { + createComponent({ date: null }); + + expect(getByText('No expiration set').exists()).toBe(true); + }); + }); + + describe('when expiration date is in the past', () => { + let expiredText; + + beforeEach(() => { + createComponent({ date: '2019-03-15T00:00:00.000' }); + + expiredText = getByText('Expired'); + }); + + it('displays "Expired"', () => { + expect(expiredText.exists()).toBe(true); + expect(expiredText.classes()).toContain('gl-text-red-500'); + }); + + it('displays tooltip with formatted date', () => { + const tooltipDirective = getTooltipDirective(expiredText); + + expect(tooltipDirective).not.toBeUndefined(); + expect(expiredText.attributes('title')).toBe('Mar 15, 2019 12:00am GMT+0000'); + }); + }); + + describe('when expiration date is in the future', () => { + it.each` + date | expected | warningColor + ${'2020-03-23T00:00:00.000'} | ${'in 8 days'} | ${false} + ${'2020-03-20T00:00:00.000'} | ${'in 5 days'} | ${true} + ${'2020-03-16T00:00:00.000'} | ${'in 1 day'} | ${true} + ${'2020-03-15T05:00:00.000'} | ${'in about 5 hours'} | ${true} + ${'2020-03-15T01:00:00.000'} | ${'in about 1 hour'} | ${true} + ${'2020-03-15T00:30:00.000'} | ${'in 30 minutes'} | ${true} + ${'2020-03-15T00:01:15.000'} | ${'in 1 minute'} | ${true} + ${'2020-03-15T00:00:15.000'} | ${'in less than a minute'} | ${true} + `('displays "$expected"', ({ date, expected, warningColor }) => { + createComponent({ date }); + + const expiredText = getByText(expected); + + expect(expiredText.exists()).toBe(true); + + if (warningColor) { + expect(expiredText.classes()).toContain('gl-text-orange-500'); + } else { + expect(expiredText.classes()).not.toContain('gl-text-orange-500'); + } + }); + }); +}); diff --git a/spec/frontend/members/components/table/member_action_buttons_spec.js b/spec/frontend/members/components/table/member_action_buttons_spec.js new file mode 100644 index 00000000000..b7a6df3d054 --- /dev/null +++ b/spec/frontend/members/components/table/member_action_buttons_spec.js @@ -0,0 +1,43 @@ +import { shallowMount } from '@vue/test-utils'; +import { MEMBER_TYPES } from '~/members/constants'; +import { member as memberMock, group, invite, accessRequest } from '../../mock_data'; +import MemberActionButtons from '~/members/components/table/member_action_buttons.vue'; +import UserActionButtons from '~/members/components/action_buttons/user_action_buttons.vue'; +import GroupActionButtons from '~/members/components/action_buttons/group_action_buttons.vue'; +import InviteActionButtons from '~/members/components/action_buttons/invite_action_buttons.vue'; +import AccessRequestActionButtons from '~/members/components/action_buttons/access_request_action_buttons.vue'; + +describe('MemberActionButtons', () => { + let wrapper; + + const createComponent = (propsData = {}) => { + wrapper = shallowMount(MemberActionButtons, { + propsData: { + isCurrentUser: false, + permissions: { + canRemove: true, + }, + ...propsData, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + test.each` + memberType | member | expectedComponent | expectedComponentName + ${MEMBER_TYPES.user} | ${memberMock} | ${UserActionButtons} | ${'UserActionButtons'} + ${MEMBER_TYPES.group} | ${group} | ${GroupActionButtons} | ${'GroupActionButtons'} + ${MEMBER_TYPES.invite} | ${invite} | ${InviteActionButtons} | ${'InviteActionButtons'} + ${MEMBER_TYPES.accessRequest} | ${accessRequest} | ${AccessRequestActionButtons} | ${'AccessRequestActionButtons'} + `( + 'renders $expectedComponentName when `memberType` is $memberType', + ({ memberType, member, expectedComponent }) => { + createComponent({ memberType, member }); + + expect(wrapper.find(expectedComponent).exists()).toBe(true); + }, + ); +}); diff --git a/spec/frontend/members/components/table/member_avatar_spec.js b/spec/frontend/members/components/table/member_avatar_spec.js new file mode 100644 index 00000000000..98177893c18 --- /dev/null +++ b/spec/frontend/members/components/table/member_avatar_spec.js @@ -0,0 +1,39 @@ +import { shallowMount } from '@vue/test-utils'; +import { MEMBER_TYPES } from '~/members/constants'; +import { member as memberMock, group, invite, accessRequest } from '../../mock_data'; +import MemberAvatar from '~/members/components/table/member_avatar.vue'; +import UserAvatar from '~/members/components/avatars/user_avatar.vue'; +import GroupAvatar from '~/members/components/avatars/group_avatar.vue'; +import InviteAvatar from '~/members/components/avatars/invite_avatar.vue'; + +describe('MemberList', () => { + let wrapper; + + const createComponent = propsData => { + wrapper = shallowMount(MemberAvatar, { + propsData: { + isCurrentUser: false, + ...propsData, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + test.each` + memberType | member | expectedComponent | expectedComponentName + ${MEMBER_TYPES.user} | ${memberMock} | ${UserAvatar} | ${'UserAvatar'} + ${MEMBER_TYPES.group} | ${group} | ${GroupAvatar} | ${'GroupAvatar'} + ${MEMBER_TYPES.invite} | ${invite} | ${InviteAvatar} | ${'InviteAvatar'} + ${MEMBER_TYPES.accessRequest} | ${accessRequest} | ${UserAvatar} | ${'UserAvatar'} + `( + 'renders $expectedComponentName when `memberType` is $memberType', + ({ memberType, member, expectedComponent }) => { + createComponent({ memberType, member }); + + expect(wrapper.find(expectedComponent).exists()).toBe(true); + }, + ); +}); diff --git a/spec/frontend/members/components/table/member_source_spec.js b/spec/frontend/members/components/table/member_source_spec.js new file mode 100644 index 00000000000..48ac06f32f6 --- /dev/null +++ b/spec/frontend/members/components/table/member_source_spec.js @@ -0,0 +1,71 @@ +import { mount, createWrapper } from '@vue/test-utils'; +import { getByText as getByTextHelper } from '@testing-library/dom'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import MemberSource from '~/members/components/table/member_source.vue'; + +describe('MemberSource', () => { + let wrapper; + + const createComponent = propsData => { + wrapper = mount(MemberSource, { + propsData: { + memberSource: { + id: 102, + name: 'Foo bar', + webUrl: 'https://gitlab.com/groups/foo-bar', + }, + ...propsData, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + const getByText = (text, options) => + createWrapper(getByTextHelper(wrapper.element, text, options)); + + const getTooltipDirective = elementWrapper => getBinding(elementWrapper.element, 'gl-tooltip'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('direct member', () => { + it('displays "Direct member"', () => { + createComponent({ + isDirectMember: true, + }); + + expect(getByText('Direct member').exists()).toBe(true); + }); + }); + + describe('inherited member', () => { + let sourceGroupLink; + + beforeEach(() => { + createComponent({ + isDirectMember: false, + }); + + sourceGroupLink = getByText('Foo bar'); + }); + + it('displays a link to source group', () => { + createComponent({ + isDirectMember: false, + }); + + expect(sourceGroupLink.exists()).toBe(true); + expect(sourceGroupLink.attributes('href')).toBe('https://gitlab.com/groups/foo-bar'); + }); + + it('displays tooltip with "Inherited"', () => { + const tooltipDirective = getTooltipDirective(sourceGroupLink); + + expect(tooltipDirective).not.toBeUndefined(); + expect(sourceGroupLink.attributes('title')).toBe('Inherited'); + }); + }); +}); diff --git a/spec/frontend/members/components/table/members_table_cell_spec.js b/spec/frontend/members/components/table/members_table_cell_spec.js new file mode 100644 index 00000000000..117c9255c00 --- /dev/null +++ b/spec/frontend/members/components/table/members_table_cell_spec.js @@ -0,0 +1,251 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { MEMBER_TYPES } from '~/members/constants'; +import { member as memberMock, group, invite, accessRequest } from '../../mock_data'; +import MembersTableCell from '~/members/components/table/members_table_cell.vue'; + +describe('MembersTableCell', () => { + const WrappedComponent = { + props: { + memberType: { + type: String, + required: true, + }, + isDirectMember: { + type: Boolean, + required: true, + }, + isCurrentUser: { + type: Boolean, + required: true, + }, + permissions: { + type: Object, + required: true, + }, + }, + render(createElement) { + return createElement('div', this.memberType); + }, + }; + + const localVue = createLocalVue(); + localVue.use(Vuex); + localVue.component('wrapped-component', WrappedComponent); + + const createStore = (state = {}) => { + return new Vuex.Store({ + state: { + sourceId: 1, + currentUserId: 1, + ...state, + }, + }); + }; + + let wrapper; + + const createComponent = (propsData, state = {}) => { + wrapper = mount(MembersTableCell, { + localVue, + propsData, + store: createStore(state), + scopedSlots: { + default: ` + <wrapped-component + :member-type="props.memberType" + :is-direct-member="props.isDirectMember" + :is-current-user="props.isCurrentUser" + :permissions="props.permissions" + /> + `, + }, + }); + }; + + const findWrappedComponent = () => wrapper.find(WrappedComponent); + + const memberCurrentUser = { + ...memberMock, + user: { + ...memberMock.user, + id: 1, + }, + }; + + const createComponentWithDirectMember = (member = {}) => { + createComponent({ + member: { + ...memberMock, + source: { + ...memberMock.source, + id: 1, + }, + ...member, + }, + }); + }; + const createComponentWithInheritedMember = (member = {}) => { + createComponent({ + member: { ...memberMock, ...member }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + test.each` + member | expectedMemberType + ${memberMock} | ${MEMBER_TYPES.user} + ${group} | ${MEMBER_TYPES.group} + ${invite} | ${MEMBER_TYPES.invite} + ${accessRequest} | ${MEMBER_TYPES.accessRequest} + `( + 'sets scoped slot prop `memberType` to $expectedMemberType', + ({ member, expectedMemberType }) => { + createComponent({ member }); + + expect(findWrappedComponent().props('memberType')).toBe(expectedMemberType); + }, + ); + + describe('isDirectMember', () => { + it('returns `true` when member source has same ID as `sourceId`', () => { + createComponentWithDirectMember(); + + expect(findWrappedComponent().props('isDirectMember')).toBe(true); + }); + + it('returns `false` when member is inherited', () => { + createComponentWithInheritedMember(); + + expect(findWrappedComponent().props('isDirectMember')).toBe(false); + }); + + it('returns `true` for linked groups', () => { + createComponent({ + member: group, + }); + + expect(findWrappedComponent().props('isDirectMember')).toBe(true); + }); + }); + + describe('isCurrentUser', () => { + it('returns `true` when `member.user` has the same ID as `currentUserId`', () => { + createComponent({ + member: memberCurrentUser, + }); + + expect(findWrappedComponent().props('isCurrentUser')).toBe(true); + }); + + it('returns `false` when `member.user` does not have the same ID as `currentUserId`', () => { + createComponent({ + member: memberMock, + }); + + expect(findWrappedComponent().props('isCurrentUser')).toBe(false); + }); + }); + + describe('permissions', () => { + describe('canRemove', () => { + describe('for a direct member', () => { + it('returns `true` when `canRemove` is `true`', () => { + createComponentWithDirectMember({ + canRemove: true, + }); + + expect(findWrappedComponent().props('permissions').canRemove).toBe(true); + }); + + it('returns `false` when `canRemove` is `false`', () => { + createComponentWithDirectMember({ + canRemove: false, + }); + + expect(findWrappedComponent().props('permissions').canRemove).toBe(false); + }); + }); + + describe('for an inherited member', () => { + it('returns `false`', () => { + createComponentWithInheritedMember(); + + expect(findWrappedComponent().props('permissions').canRemove).toBe(false); + }); + }); + }); + + describe('canResend', () => { + describe('when member type is `invite`', () => { + it('returns `true` when `canResend` is `true`', () => { + createComponent({ + member: invite, + }); + + expect(findWrappedComponent().props('permissions').canResend).toBe(true); + }); + + it('returns `false` when `canResend` is `false`', () => { + createComponent({ + member: { + ...invite, + invite: { + ...invite, + canResend: false, + }, + }, + }); + + expect(findWrappedComponent().props('permissions').canResend).toBe(false); + }); + }); + + describe('when member type is not `invite`', () => { + it('returns `false`', () => { + createComponent({ member: memberMock }); + + expect(findWrappedComponent().props('permissions').canResend).toBe(false); + }); + }); + }); + + describe('canUpdate', () => { + describe('for a direct member', () => { + it('returns `true` when `canUpdate` is `true`', () => { + createComponentWithDirectMember({ + canUpdate: true, + }); + + expect(findWrappedComponent().props('permissions').canUpdate).toBe(true); + }); + + it('returns `false` when `canUpdate` is `false`', () => { + createComponentWithDirectMember({ + canUpdate: false, + }); + + expect(findWrappedComponent().props('permissions').canUpdate).toBe(false); + }); + + it('returns `false` for current user', () => { + createComponentWithDirectMember(memberCurrentUser); + + expect(findWrappedComponent().props('permissions').canUpdate).toBe(false); + }); + }); + + describe('for an inherited member', () => { + it('returns `false`', () => { + createComponentWithInheritedMember(); + + expect(findWrappedComponent().props('permissions').canUpdate).toBe(false); + }); + }); + }); + }); +}); diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js new file mode 100644 index 00000000000..9945cc7ee57 --- /dev/null +++ b/spec/frontend/members/components/table/members_table_spec.js @@ -0,0 +1,212 @@ +import { mount, createLocalVue, createWrapper } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { + getByText as getByTextHelper, + getByTestId as getByTestIdHelper, + within, +} from '@testing-library/dom'; +import { GlBadge, GlTable } from '@gitlab/ui'; +import MembersTable from '~/members/components/table/members_table.vue'; +import MemberAvatar from '~/members/components/table/member_avatar.vue'; +import MemberSource from '~/members/components/table/member_source.vue'; +import ExpiresAt from '~/members/components/table/expires_at.vue'; +import CreatedAt from '~/members/components/table/created_at.vue'; +import RoleDropdown from '~/members/components/table/role_dropdown.vue'; +import ExpirationDatepicker from '~/members/components/table/expiration_datepicker.vue'; +import MemberActionButtons from '~/members/components/table/member_action_buttons.vue'; +import * as initUserPopovers from '~/user_popovers'; +import { member as memberMock, invite, accessRequest } from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('MembersTable', () => { + let wrapper; + + const createStore = (state = {}) => { + return new Vuex.Store({ + state: { + members: [], + tableFields: [], + tableAttrs: { + table: { 'data-qa-selector': 'members_list' }, + tr: { 'data-qa-selector': 'member_row' }, + }, + sourceId: 1, + currentUserId: 1, + ...state, + }, + }); + }; + + const createComponent = state => { + wrapper = mount(MembersTable, { + localVue, + store: createStore(state), + stubs: [ + 'member-avatar', + 'member-source', + 'expires-at', + 'created-at', + 'member-action-buttons', + 'role-dropdown', + 'remove-group-link-modal', + 'expiration-datepicker', + ], + }); + }; + + const getByText = (text, options) => + createWrapper(getByTextHelper(wrapper.element, text, options)); + + const getByTestId = (id, options) => + createWrapper(getByTestIdHelper(wrapper.element, id, options)); + + const findTable = () => wrapper.find(GlTable); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('fields', () => { + const directMember = { + ...memberMock, + source: { ...memberMock.source, id: 1 }, + }; + + const memberCanUpdate = { + ...directMember, + canUpdate: true, + }; + + it.each` + field | label | member | expectedComponent + ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar} + ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource} + ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt} + ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt} + ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt} + ${'expires'} | ${'Access expires'} | ${memberMock} | ${ExpiresAt} + ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown} + ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker} + `('renders the $label field', ({ field, label, member, expectedComponent }) => { + createComponent({ + members: [member], + tableFields: [field], + }); + + expect(getByText(label, { selector: '[role="columnheader"]' }).exists()).toBe(true); + + if (expectedComponent) { + expect( + wrapper + .find(`[data-label="${label}"][role="cell"]`) + .find(expectedComponent) + .exists(), + ).toBe(true); + } + }); + + describe('"Actions" field', () => { + it('renders "Actions" field for screen readers', () => { + createComponent({ members: [memberCanUpdate], tableFields: ['actions'] }); + + const actionField = getByTestId('col-actions'); + + expect(actionField.exists()).toBe(true); + expect(actionField.classes('gl-sr-only')).toBe(true); + expect( + wrapper + .find(`[data-label="Actions"][role="cell"]`) + .find(MemberActionButtons) + .exists(), + ).toBe(true); + }); + + describe('when user is not logged in', () => { + it('does not render the "Actions" field', () => { + createComponent({ currentUserId: null, tableFields: ['actions'] }); + + expect(within(wrapper.element).queryByTestId('col-actions')).toBe(null); + }); + }); + + const memberCanRemove = { + ...directMember, + canRemove: true, + }; + + describe.each` + permission | members + ${'canUpdate'} | ${[memberCanUpdate]} + ${'canRemove'} | ${[memberCanRemove]} + ${'canResend'} | ${[invite]} + `('when one of the members has $permission permissions', ({ members }) => { + it('renders the "Actions" field', () => { + createComponent({ members, tableFields: ['actions'] }); + + expect(getByTestId('col-actions').exists()).toBe(true); + }); + }); + + describe.each` + permission | members + ${'canUpdate'} | ${[memberMock]} + ${'canRemove'} | ${[memberMock]} + ${'canResend'} | ${[{ ...invite, invite: { ...invite.invite, canResend: false } }]} + `('when none of the members have $permission permissions', ({ members }) => { + it('does not render the "Actions" field', () => { + createComponent({ members, tableFields: ['actions'] }); + + expect(within(wrapper.element).queryByTestId('col-actions')).toBe(null); + }); + }); + }); + }); + + describe('when `members` is an empty array', () => { + it('displays a "No members found" message', () => { + createComponent(); + + expect(getByText('No members found').exists()).toBe(true); + }); + }); + + describe('when member can not be updated', () => { + it('renders badge in "Max role" field', () => { + createComponent({ members: [memberMock], tableFields: ['maxRole'] }); + + expect( + wrapper + .find(`[data-label="Max role"][role="cell"]`) + .find(GlBadge) + .text(), + ).toBe(memberMock.accessLevel.stringValue); + }); + }); + + it('initializes user popovers when mounted', () => { + const initUserPopoversMock = jest.spyOn(initUserPopovers, 'default'); + + createComponent(); + + expect(initUserPopoversMock).toHaveBeenCalled(); + }); + + it('adds QA selector to table', () => { + createComponent(); + + expect(findTable().attributes('data-qa-selector')).toBe('members_list'); + }); + + it('adds QA selector to table row', () => { + createComponent(); + + expect( + findTable() + .find('tbody tr') + .attributes('data-qa-selector'), + ).toBe('member_row'); + }); +}); diff --git a/spec/frontend/members/components/table/role_dropdown_spec.js b/spec/frontend/members/components/table/role_dropdown_spec.js new file mode 100644 index 00000000000..6c6abf35bd7 --- /dev/null +++ b/spec/frontend/members/components/table/role_dropdown_spec.js @@ -0,0 +1,151 @@ +import { mount, createWrapper, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { nextTick } from 'vue'; +import { within } from '@testing-library/dom'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import RoleDropdown from '~/members/components/table/role_dropdown.vue'; +import { member } from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('RoleDropdown', () => { + let wrapper; + let actions; + const $toast = { + show: jest.fn(), + }; + + const createStore = () => { + actions = { + updateMemberRole: jest.fn(() => Promise.resolve()), + }; + + return new Vuex.Store({ actions }); + }; + + const createComponent = (propsData = {}) => { + wrapper = mount(RoleDropdown, { + propsData: { + member, + permissions: {}, + ...propsData, + }, + localVue, + store: createStore(), + mocks: { + $toast, + }, + }); + }; + + const getDropdownMenu = () => within(wrapper.element).getByRole('menu'); + const getByTextInDropdownMenu = (text, options = {}) => + createWrapper(within(getDropdownMenu()).getByText(text, options)); + const getDropdownItemByText = text => + createWrapper( + within(getDropdownMenu()) + .getByText(text, { selector: '[role="menuitem"] p' }) + .closest('[role="menuitem"]'), + ); + const getCheckedDropdownItem = () => + wrapper + .findAll(GlDropdownItem) + .wrappers.find(dropdownItemWrapper => dropdownItemWrapper.props('isChecked')); + + const findDropdownToggle = () => wrapper.find('button[aria-haspopup="true"]'); + const findDropdown = () => wrapper.find(GlDropdown); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when dropdown is open', () => { + beforeEach(done => { + createComponent(); + + findDropdownToggle().trigger('click'); + wrapper.vm.$root.$on('bv::dropdown::shown', () => { + done(); + }); + }); + + it('renders all valid roles', () => { + Object.keys(member.validRoles).forEach(role => { + expect(getDropdownItemByText(role).exists()).toBe(true); + }); + }); + + it('renders dropdown header', () => { + expect(getByTextInDropdownMenu('Change permissions').exists()).toBe(true); + }); + + it('sets dropdown toggle and checks selected role', () => { + expect(findDropdownToggle().text()).toBe('Owner'); + expect(getCheckedDropdownItem().text()).toBe('Owner'); + }); + + describe('when dropdown item is selected', () => { + it('does nothing if the item selected was already selected', () => { + getDropdownItemByText('Owner').trigger('click'); + + expect(actions.updateMemberRole).not.toHaveBeenCalled(); + }); + + it('calls `updateMemberRole` Vuex action', () => { + getDropdownItemByText('Developer').trigger('click'); + + expect(actions.updateMemberRole).toHaveBeenCalledWith(expect.any(Object), { + memberId: member.id, + accessLevel: { integerValue: 30, stringValue: 'Developer' }, + }); + }); + + it('displays toast when successful', async () => { + getDropdownItemByText('Developer').trigger('click'); + + await waitForPromises(); + + expect($toast.show).toHaveBeenCalledWith('Role updated successfully.'); + }); + + it('disables dropdown while waiting for `updateMemberRole` to resolve', async () => { + getDropdownItemByText('Developer').trigger('click'); + + await nextTick(); + + expect(findDropdown().props('disabled')).toBe(true); + + await waitForPromises(); + + expect(findDropdown().props('disabled')).toBe(false); + }); + }); + }); + + it("sets initial dropdown toggle value to member's role", () => { + createComponent(); + + expect(findDropdownToggle().text()).toBe('Owner'); + }); + + it('sets the dropdown alignment to right on mobile', async () => { + jest.spyOn(bp, 'isDesktop').mockReturnValue(false); + createComponent(); + + await nextTick(); + + expect(findDropdown().props('right')).toBe(true); + }); + + it('sets the dropdown alignment to left on desktop', async () => { + jest.spyOn(bp, 'isDesktop').mockReturnValue(true); + createComponent(); + + await nextTick(); + + expect(findDropdown().props('right')).toBe(false); + }); +}); diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js new file mode 100644 index 00000000000..5674929716d --- /dev/null +++ b/spec/frontend/members/mock_data.js @@ -0,0 +1,71 @@ +export const member = { + requestedAt: null, + canUpdate: false, + canRemove: false, + canOverride: false, + isOverridden: false, + accessLevel: { integerValue: 50, stringValue: 'Owner' }, + source: { + id: 178, + name: 'Foo Bar', + webUrl: 'https://gitlab.com/groups/foo-bar', + }, + user: { + id: 123, + name: 'Administrator', + username: 'root', + webUrl: 'https://gitlab.com/root', + avatarUrl: 'https://www.gravatar.com/avatar/4816142ef496f956a277bedf1a40607b?s=80&d=identicon', + blocked: false, + twoFactorEnabled: false, + }, + id: 238, + createdAt: '2020-07-17T16:22:46.923Z', + expiresAt: null, + usingLicense: false, + groupSso: false, + groupManagedAccount: false, + validRoles: { + Guest: 10, + Reporter: 20, + Developer: 30, + Maintainer: 40, + Owner: 50, + 'Minimal Access': 5, + }, +}; + +export const group = { + accessLevel: { integerValue: 10, stringValue: 'Guest' }, + sharedWithGroup: { + id: 24, + name: 'Commit451', + avatarUrl: '/uploads/-/system/user/avatar/1/avatar.png?width=40', + fullPath: 'parent-group/commit451', + fullName: 'Parent group / Commit451', + webUrl: 'https://gitlab.com/groups/parent-group/commit451', + }, + id: 3, + createdAt: '2020-08-06T15:31:07.662Z', + expiresAt: null, + validRoles: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }, +}; + +const { user, ...memberNoUser } = member; +export const invite = { + ...memberNoUser, + invite: { + email: 'jewel@hudsonwalter.biz', + avatarUrl: 'https://www.gravatar.com/avatar/cbab7510da7eec2f60f638261b05436d?s=80&d=identicon', + canResend: true, + }, +}; + +export const orphanedMember = memberNoUser; + +export const accessRequest = { + ...member, + requestedAt: '2020-07-17T16:22:46.923Z', +}; + +export const members = [member]; diff --git a/spec/frontend/members/store/actions_spec.js b/spec/frontend/members/store/actions_spec.js new file mode 100644 index 00000000000..5424fee0750 --- /dev/null +++ b/spec/frontend/members/store/actions_spec.js @@ -0,0 +1,152 @@ +import { noop } from 'lodash'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { members, group } from 'jest/members/mock_data'; +import testAction from 'helpers/vuex_action_helper'; +import { useFakeDate } from 'helpers/fake_date'; +import httpStatusCodes from '~/lib/utils/http_status'; +import * as types from '~/members/store/mutation_types'; +import { + updateMemberRole, + showRemoveGroupLinkModal, + hideRemoveGroupLinkModal, + updateMemberExpiration, +} from '~/members/store/actions'; + +describe('Vuex members actions', () => { + describe('update member actions', () => { + let mock; + + const state = { + members, + memberPath: '/groups/foo-bar/-/group_members/:id', + requestFormatter: noop, + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('updateMemberRole', () => { + const memberId = members[0].id; + const accessLevel = { integerValue: 30, stringValue: 'Developer' }; + + const payload = { + memberId, + accessLevel, + }; + + describe('successful request', () => { + it(`commits ${types.RECEIVE_MEMBER_ROLE_SUCCESS} mutation`, async () => { + mock.onPut().replyOnce(httpStatusCodes.OK); + + await testAction(updateMemberRole, payload, state, [ + { + type: types.RECEIVE_MEMBER_ROLE_SUCCESS, + payload, + }, + ]); + + expect(mock.history.put[0].url).toBe('/groups/foo-bar/-/group_members/238'); + }); + }); + + describe('unsuccessful request', () => { + it(`commits ${types.RECEIVE_MEMBER_ROLE_ERROR} mutation and throws error`, async () => { + mock.onPut().networkError(); + + await expect( + testAction(updateMemberRole, payload, state, [ + { + type: types.RECEIVE_MEMBER_ROLE_ERROR, + }, + ]), + ).rejects.toThrowError(new Error('Network Error')); + }); + }); + }); + + describe('updateMemberExpiration', () => { + useFakeDate(2020, 2, 15, 3); + + const memberId = members[0].id; + const expiresAt = '2020-3-17'; + + describe('successful request', () => { + describe('changing expiration date', () => { + it(`commits ${types.RECEIVE_MEMBER_EXPIRATION_SUCCESS} mutation`, async () => { + mock.onPut().replyOnce(httpStatusCodes.OK); + + await testAction(updateMemberExpiration, { memberId, expiresAt }, state, [ + { + type: types.RECEIVE_MEMBER_EXPIRATION_SUCCESS, + payload: { memberId, expiresAt: '2020-03-17T00:00:00Z' }, + }, + ]); + + expect(mock.history.put[0].url).toBe('/groups/foo-bar/-/group_members/238'); + }); + }); + + describe('removing the expiration date', () => { + it(`commits ${types.RECEIVE_MEMBER_EXPIRATION_SUCCESS} mutation`, async () => { + mock.onPut().replyOnce(httpStatusCodes.OK); + + await testAction(updateMemberExpiration, { memberId, expiresAt: null }, state, [ + { + type: types.RECEIVE_MEMBER_EXPIRATION_SUCCESS, + payload: { memberId, expiresAt: null }, + }, + ]); + }); + }); + }); + + describe('unsuccessful request', () => { + it(`commits ${types.RECEIVE_MEMBER_EXPIRATION_ERROR} mutation and throws error`, async () => { + mock.onPut().networkError(); + + await expect( + testAction(updateMemberExpiration, { memberId, expiresAt }, state, [ + { + type: types.RECEIVE_MEMBER_EXPIRATION_ERROR, + }, + ]), + ).rejects.toThrowError(new Error('Network Error')); + }); + }); + }); + }); + + describe('Group Link Modal', () => { + const state = { + removeGroupLinkModalVisible: false, + groupLinkToRemove: null, + }; + + describe('showRemoveGroupLinkModal', () => { + it(`commits ${types.SHOW_REMOVE_GROUP_LINK_MODAL} mutation`, () => { + testAction(showRemoveGroupLinkModal, group, state, [ + { + type: types.SHOW_REMOVE_GROUP_LINK_MODAL, + payload: group, + }, + ]); + }); + }); + + describe('hideRemoveGroupLinkModal', () => { + it(`commits ${types.HIDE_REMOVE_GROUP_LINK_MODAL} mutation`, () => { + testAction(hideRemoveGroupLinkModal, group, state, [ + { + type: types.HIDE_REMOVE_GROUP_LINK_MODAL, + }, + ]); + }); + }); + }); +}); diff --git a/spec/frontend/members/store/mutations_spec.js b/spec/frontend/members/store/mutations_spec.js new file mode 100644 index 00000000000..488bfdf15fd --- /dev/null +++ b/spec/frontend/members/store/mutations_spec.js @@ -0,0 +1,117 @@ +import { members, group } from 'jest/members/mock_data'; +import mutations from '~/members/store/mutations'; +import * as types from '~/members/store/mutation_types'; + +describe('Vuex members mutations', () => { + describe('update member mutations', () => { + let state; + + beforeEach(() => { + state = { + members, + showError: false, + errorMessage: '', + }; + }); + + describe(types.RECEIVE_MEMBER_ROLE_SUCCESS, () => { + it('updates member', () => { + const accessLevel = { integerValue: 30, stringValue: 'Developer' }; + + mutations[types.RECEIVE_MEMBER_ROLE_SUCCESS](state, { + memberId: members[0].id, + accessLevel, + }); + + expect(state.members[0].accessLevel).toEqual(accessLevel); + }); + }); + + describe(types.RECEIVE_MEMBER_ROLE_ERROR, () => { + it('shows error message', () => { + mutations[types.RECEIVE_MEMBER_ROLE_ERROR](state); + + expect(state.showError).toBe(true); + expect(state.errorMessage).toBe( + "An error occurred while updating the member's role, please try again.", + ); + }); + }); + + describe(types.RECEIVE_MEMBER_EXPIRATION_SUCCESS, () => { + it('updates member', () => { + const expiresAt = '2020-03-17T00:00:00Z'; + + mutations[types.RECEIVE_MEMBER_EXPIRATION_SUCCESS](state, { + memberId: members[0].id, + expiresAt, + }); + + expect(state.members[0].expiresAt).toEqual(expiresAt); + }); + }); + + describe(types.RECEIVE_MEMBER_EXPIRATION_ERROR, () => { + it('shows error message', () => { + mutations[types.RECEIVE_MEMBER_EXPIRATION_ERROR](state); + + expect(state.showError).toBe(true); + expect(state.errorMessage).toBe( + "An error occurred while updating the member's expiration date, please try again.", + ); + }); + }); + }); + + describe(types.HIDE_ERROR, () => { + it('sets `showError` to `false`', () => { + const state = { + showError: true, + errorMessage: 'foo bar', + }; + + mutations[types.HIDE_ERROR](state); + + expect(state.showError).toBe(false); + }); + + it('sets `errorMessage` to an empty string', () => { + const state = { + showError: true, + errorMessage: 'foo bar', + }; + + mutations[types.HIDE_ERROR](state); + + expect(state.errorMessage).toBe(''); + }); + }); + + describe(types.SHOW_REMOVE_GROUP_LINK_MODAL, () => { + it('sets `removeGroupLinkModalVisible` and `groupLinkToRemove`', () => { + const state = { + removeGroupLinkModalVisible: false, + groupLinkToRemove: null, + }; + + mutations[types.SHOW_REMOVE_GROUP_LINK_MODAL](state, group); + + expect(state).toEqual({ + removeGroupLinkModalVisible: true, + groupLinkToRemove: group, + }); + }); + }); + + describe(types.HIDE_REMOVE_GROUP_LINK_MODAL, () => { + it('sets `removeGroupLinkModalVisible` to `false`', () => { + const state = { + removeGroupLinkModalVisible: false, + }; + + mutations[types.HIDE_REMOVE_GROUP_LINK_MODAL](state); + + expect(state.removeGroupLinkModalVisible).toBe(false); + }); + }); +}); diff --git a/spec/frontend/members/store/utils_spec.js b/spec/frontend/members/store/utils_spec.js new file mode 100644 index 00000000000..e3cde38269c --- /dev/null +++ b/spec/frontend/members/store/utils_spec.js @@ -0,0 +1,14 @@ +import { members } from 'jest/members/mock_data'; +import { findMember } from '~/members/store/utils'; + +describe('Members Vuex utils', () => { + describe('findMember', () => { + it('finds member by ID', () => { + const state = { + members, + }; + + expect(findMember(state, members[0].id)).toEqual(members[0]); + }); + }); +}); diff --git a/spec/frontend/members/utils_spec.js b/spec/frontend/members/utils_spec.js new file mode 100644 index 00000000000..7bbfddf8fc6 --- /dev/null +++ b/spec/frontend/members/utils_spec.js @@ -0,0 +1,232 @@ +import { + generateBadges, + isGroup, + isDirectMember, + isCurrentUser, + canRemove, + canResend, + canUpdate, + canOverride, + parseSortParam, + buildSortHref, +} from '~/members/utils'; +import { DEFAULT_SORT } from '~/members/constants'; +import { member as memberMock, group, invite } from './mock_data'; + +const DIRECT_MEMBER_ID = 178; +const INHERITED_MEMBER_ID = 179; +const IS_CURRENT_USER_ID = 123; +const IS_NOT_CURRENT_USER_ID = 124; +const URL_HOST = 'https://localhost/'; + +describe('Members Utils', () => { + describe('generateBadges', () => { + it('has correct properties for each badge', () => { + const badges = generateBadges(memberMock, true); + + badges.forEach(badge => { + expect(badge).toEqual( + expect.objectContaining({ + show: expect.any(Boolean), + text: expect.any(String), + variant: expect.stringMatching(/muted|neutral|info|success|danger|warning/), + }), + ); + }); + }); + + it.each` + member | expected + ${memberMock} | ${{ show: true, text: "It's you", variant: 'success' }} + ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${{ show: true, text: 'Blocked', variant: 'danger' }} + ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${{ show: true, text: '2FA', variant: 'info' }} + `('returns expected output for "$expected.text" badge', ({ member, expected }) => { + expect(generateBadges(member, true)).toContainEqual(expect.objectContaining(expected)); + }); + }); + + describe('isGroup', () => { + test.each` + member | expected + ${group} | ${true} + ${memberMock} | ${false} + `('returns $expected', ({ member, expected }) => { + expect(isGroup(member)).toBe(expected); + }); + }); + + describe('isDirectMember', () => { + test.each` + sourceId | expected + ${DIRECT_MEMBER_ID} | ${true} + ${INHERITED_MEMBER_ID} | ${false} + `('returns $expected', ({ sourceId, expected }) => { + expect(isDirectMember(memberMock, sourceId)).toBe(expected); + }); + }); + + describe('isCurrentUser', () => { + test.each` + currentUserId | expected + ${IS_CURRENT_USER_ID} | ${true} + ${IS_NOT_CURRENT_USER_ID} | ${false} + `('returns $expected', ({ currentUserId, expected }) => { + expect(isCurrentUser(memberMock, currentUserId)).toBe(expected); + }); + }); + + describe('canRemove', () => { + const memberCanRemove = { + ...memberMock, + canRemove: true, + }; + + test.each` + member | sourceId | expected + ${memberCanRemove} | ${DIRECT_MEMBER_ID} | ${true} + ${memberCanRemove} | ${INHERITED_MEMBER_ID} | ${false} + ${memberMock} | ${INHERITED_MEMBER_ID} | ${false} + `('returns $expected', ({ member, sourceId, expected }) => { + expect(canRemove(member, sourceId)).toBe(expected); + }); + }); + + describe('canResend', () => { + test.each` + member | expected + ${invite} | ${true} + ${{ ...invite, invite: { ...invite.invite, canResend: false } }} | ${false} + `('returns $expected', ({ member, sourceId, expected }) => { + expect(canResend(member, sourceId)).toBe(expected); + }); + }); + + describe('canUpdate', () => { + const memberCanUpdate = { + ...memberMock, + canUpdate: true, + }; + + test.each` + member | currentUserId | sourceId | expected + ${memberCanUpdate} | ${IS_NOT_CURRENT_USER_ID} | ${DIRECT_MEMBER_ID} | ${true} + ${memberCanUpdate} | ${IS_CURRENT_USER_ID} | ${DIRECT_MEMBER_ID} | ${false} + ${memberCanUpdate} | ${IS_CURRENT_USER_ID} | ${INHERITED_MEMBER_ID} | ${false} + ${memberMock} | ${IS_NOT_CURRENT_USER_ID} | ${DIRECT_MEMBER_ID} | ${false} + `('returns $expected', ({ member, currentUserId, sourceId, expected }) => { + expect(canUpdate(member, currentUserId, sourceId)).toBe(expected); + }); + }); + + describe('canOverride', () => { + it('returns `false`', () => { + expect(canOverride(memberMock)).toBe(false); + }); + }); + + describe('parseSortParam', () => { + beforeEach(() => { + delete window.location; + window.location = new URL(URL_HOST); + }); + + describe('when `sort` param is not present', () => { + it('returns default sort options', () => { + window.location.search = ''; + + expect(parseSortParam(['account'])).toEqual(DEFAULT_SORT); + }); + }); + + describe('when field passed in `sortableFields` argument does not have `sort` key defined', () => { + it('returns default sort options', () => { + window.location.search = '?sort=source_asc'; + + expect(parseSortParam(['source'])).toEqual(DEFAULT_SORT); + }); + }); + + describe.each` + sortParam | expected + ${'name_asc'} | ${{ sortByKey: 'account', sortDesc: false }} + ${'name_desc'} | ${{ sortByKey: 'account', sortDesc: true }} + ${'last_joined'} | ${{ sortByKey: 'granted', sortDesc: false }} + ${'oldest_joined'} | ${{ sortByKey: 'granted', sortDesc: true }} + ${'access_level_asc'} | ${{ sortByKey: 'maxRole', sortDesc: false }} + ${'access_level_desc'} | ${{ sortByKey: 'maxRole', sortDesc: true }} + ${'recent_sign_in'} | ${{ sortByKey: 'lastSignIn', sortDesc: false }} + ${'oldest_sign_in'} | ${{ sortByKey: 'lastSignIn', sortDesc: true }} + `('when `sort` query string param is `$sortParam`', ({ sortParam, expected }) => { + it(`returns ${JSON.stringify(expected)}`, async () => { + window.location.search = `?sort=${sortParam}`; + + expect(parseSortParam(['account', 'granted', 'expires', 'maxRole', 'lastSignIn'])).toEqual( + expected, + ); + }); + }); + }); + + describe('buildSortHref', () => { + beforeEach(() => { + delete window.location; + window.location = new URL(URL_HOST); + }); + + describe('when field passed in `sortBy` argument does not have `sort` key defined', () => { + it('returns an empty string', () => { + expect( + buildSortHref({ + sortBy: 'source', + sortDesc: false, + filteredSearchBarTokens: [], + filteredSearchBarSearchParam: 'search', + }), + ).toBe(''); + }); + }); + + describe('when there are no filter params set', () => { + it('sets `sort` param', () => { + expect( + buildSortHref({ + sortBy: 'account', + sortDesc: false, + filteredSearchBarTokens: [], + filteredSearchBarSearchParam: 'search', + }), + ).toBe(`${URL_HOST}?sort=name_asc`); + }); + }); + + describe('when filter params are set', () => { + it('merges the `sort` param with the filter params', () => { + window.location.search = '?two_factor=enabled&with_inherited_permissions=exclude'; + + expect( + buildSortHref({ + sortBy: 'account', + sortDesc: false, + filteredSearchBarTokens: ['two_factor', 'with_inherited_permissions'], + filteredSearchBarSearchParam: 'search', + }), + ).toBe(`${URL_HOST}?two_factor=enabled&with_inherited_permissions=exclude&sort=name_asc`); + }); + }); + + describe('when search param is set', () => { + it('merges the `sort` param with the search param', () => { + window.location.search = '?search=foobar'; + + expect( + buildSortHref({ + sortBy: 'account', + sortDesc: false, + filteredSearchBarTokens: ['two_factor', 'with_inherited_permissions'], + filteredSearchBarSearchParam: 'search', + }), + ).toBe(`${URL_HOST}?search=foobar&sort=name_asc`); + }); + }); + }); +}); |