diff options
Diffstat (limited to 'spec/frontend/vue_shared/components/members')
23 files changed, 1948 insertions, 0 deletions
diff --git a/spec/frontend/vue_shared/components/members/action_buttons/access_request_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/access_request_action_buttons_spec.js new file mode 100644 index 00000000000..58cb8ef61d1 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/action_buttons/access_request_action_buttons_spec.js @@ -0,0 +1,108 @@ +import { shallowMount } from '@vue/test-utils'; +import AccessRequestActionButtons from '~/vue_shared/components/members/action_buttons/access_request_action_buttons.vue'; +import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue'; +import ApproveAccessRequestButton from '~/vue_shared/components/members/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/vue_shared/components/members/action_buttons/approve_access_request_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/approve_access_request_button_spec.js new file mode 100644 index 00000000000..93edaaa400d --- /dev/null +++ b/spec/frontend/vue_shared/components/members/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 '~/vue_shared/components/members/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/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js new file mode 100644 index 00000000000..1374cdc6aef --- /dev/null +++ b/spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js @@ -0,0 +1,85 @@ +import { shallowMount } from '@vue/test-utils'; +import InviteActionButtons from '~/vue_shared/components/members/action_buttons/invite_action_buttons.vue'; +import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue'; +import ResendInviteButton from '~/vue_shared/components/members/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/vue_shared/components/members/action_buttons/leave_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/leave_button_spec.js new file mode 100644 index 00000000000..00896b23b95 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/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 '~/vue_shared/components/members/action_buttons/leave_button.vue'; +import LeaveModal from '~/vue_shared/components/members/modals/leave_modal.vue'; +import { LEAVE_MODAL_ID } from '~/vue_shared/components/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/vue_shared/components/members/action_buttons/remove_group_link_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/remove_group_link_button_spec.js new file mode 100644 index 00000000000..84fe1c51773 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/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 '~/vue_shared/components/members/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/vue_shared/components/members/action_buttons/remove_member_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/remove_member_button_spec.js new file mode 100644 index 00000000000..7aa30494234 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/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 '~/vue_shared/components/members/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/vue_shared/components/members/action_buttons/resend_invite_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/resend_invite_button_spec.js new file mode 100644 index 00000000000..859fdd01043 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/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 '~/vue_shared/components/members/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/vue_shared/components/members/action_buttons/user_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/user_action_buttons_spec.js new file mode 100644 index 00000000000..f766ad5b0d1 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/action_buttons/user_action_buttons_spec.js @@ -0,0 +1,89 @@ +import { shallowMount } from '@vue/test-utils'; +import UserActionButtons from '~/vue_shared/components/members/action_buttons/user_action_buttons.vue'; +import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue'; +import LeaveButton from '~/vue_shared/components/members/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/vue_shared/components/members/avatars/group_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js new file mode 100644 index 00000000000..d6f5773295c --- /dev/null +++ b/spec/frontend/vue_shared/components/members/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 '~/vue_shared/components/members/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/vue_shared/components/members/avatars/invite_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js new file mode 100644 index 00000000000..7948da7eb40 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/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 '~/vue_shared/components/members/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/vue_shared/components/members/avatars/user_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js new file mode 100644 index 00000000000..93d8e640968 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/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 '~/vue_shared/components/members/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/vue_shared/components/members/mock_data.js b/spec/frontend/vue_shared/components/members/mock_data.js new file mode 100644 index 00000000000..d7bb8c0d142 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/mock_data.js @@ -0,0 +1,70 @@ +export const member = { + requestedAt: null, + canUpdate: false, + canRemove: false, + canOverride: 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/vue_shared/components/members/modals/leave_modal_spec.js b/spec/frontend/vue_shared/components/members/modals/leave_modal_spec.js new file mode 100644 index 00000000000..63de355a3c8 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/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 '~/vue_shared/components/members/modals/leave_modal.vue'; +import { LEAVE_MODAL_ID } from '~/vue_shared/components/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/vue_shared/components/members/modals/remove_group_link_modal_spec.js b/spec/frontend/vue_shared/components/members/modals/remove_group_link_modal_spec.js new file mode 100644 index 00000000000..84da051792d --- /dev/null +++ b/spec/frontend/vue_shared/components/members/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 '~/vue_shared/components/members/modals/remove_group_link_modal.vue'; +import { REMOVE_GROUP_LINK_MODAL_ID } from '~/vue_shared/components/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/vue_shared/components/members/table/created_at_spec.js b/spec/frontend/vue_shared/components/members/table/created_at_spec.js new file mode 100644 index 00000000000..cf3821baf44 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/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 '~/vue_shared/components/members/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/vue_shared/components/members/table/expires_at_spec.js b/spec/frontend/vue_shared/components/members/table/expires_at_spec.js new file mode 100644 index 00000000000..95ae251b0fd --- /dev/null +++ b/spec/frontend/vue_shared/components/members/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 '~/vue_shared/components/members/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/vue_shared/components/members/table/member_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/table/member_action_buttons_spec.js new file mode 100644 index 00000000000..e55d9b6be2a --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/member_action_buttons_spec.js @@ -0,0 +1,43 @@ +import { shallowMount } from '@vue/test-utils'; +import { MEMBER_TYPES } from '~/vue_shared/components/members/constants'; +import { member as memberMock, group, invite, accessRequest } from '../mock_data'; +import MemberActionButtons from '~/vue_shared/components/members/table/member_action_buttons.vue'; +import UserActionButtons from '~/vue_shared/components/members/action_buttons/user_action_buttons.vue'; +import GroupActionButtons from '~/vue_shared/components/members/action_buttons/group_action_buttons.vue'; +import InviteActionButtons from '~/vue_shared/components/members/action_buttons/invite_action_buttons.vue'; +import AccessRequestActionButtons from '~/vue_shared/components/members/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/vue_shared/components/members/table/member_avatar_spec.js b/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js new file mode 100644 index 00000000000..a171dd830c1 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js @@ -0,0 +1,39 @@ +import { shallowMount } from '@vue/test-utils'; +import { MEMBER_TYPES } from '~/vue_shared/components/members/constants'; +import { member as memberMock, group, invite, accessRequest } from '../mock_data'; +import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue'; +import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue'; +import GroupAvatar from '~/vue_shared/components/members/avatars/group_avatar.vue'; +import InviteAvatar from '~/vue_shared/components/members/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/vue_shared/components/members/table/member_source_spec.js b/spec/frontend/vue_shared/components/members/table/member_source_spec.js new file mode 100644 index 00000000000..8b914d76674 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/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 '~/vue_shared/components/members/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/vue_shared/components/members/table/member_table_cell_spec.js b/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js new file mode 100644 index 00000000000..ba693975a88 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js @@ -0,0 +1,251 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { MEMBER_TYPES } from '~/vue_shared/components/members/constants'; +import { member as memberMock, group, invite, accessRequest } from '../mock_data'; +import MembersTableCell from '~/vue_shared/components/members/table/members_table_cell.vue'; + +describe('MemberList', () => { + 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/vue_shared/components/members/table/members_table_spec.js b/spec/frontend/vue_shared/components/members/table/members_table_spec.js new file mode 100644 index 00000000000..20c1c26d2ee --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/members_table_spec.js @@ -0,0 +1,141 @@ +import { mount, createLocalVue, createWrapper } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { + getByText as getByTextHelper, + getByTestId as getByTestIdHelper, +} from '@testing-library/dom'; +import { GlBadge } from '@gitlab/ui'; +import MembersTable from '~/vue_shared/components/members/table/members_table.vue'; +import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue'; +import MemberSource from '~/vue_shared/components/members/table/member_source.vue'; +import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue'; +import CreatedAt from '~/vue_shared/components/members/table/created_at.vue'; +import RoleDropdown from '~/vue_shared/components/members/table/role_dropdown.vue'; +import MemberActionButtons from '~/vue_shared/components/members/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('MemberList', () => { + let wrapper; + + const createStore = (state = {}) => { + return new Vuex.Store({ + state: { + members: [], + tableFields: [], + sourceId: 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', + ], + }); + }; + + const getByText = (text, options) => + createWrapper(getByTextHelper(wrapper.element, text, options)); + + const getByTestId = (id, options) => + createWrapper(getByTestIdHelper(wrapper.element, id, options)); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('fields', () => { + const memberCanUpdate = { + ...memberMock, + canUpdate: true, + source: { ...memberMock.source, id: 1 }, + }; + + 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} | ${null} + `('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); + } + }); + + it('renders "Actions" field for screen readers', () => { + createComponent({ members: [memberMock], 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 `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(); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js b/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js new file mode 100644 index 00000000000..1e47953a510 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js @@ -0,0 +1,150 @@ +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 '~/vue_shared/components/members/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, + ...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().attributes('disabled')).toBe('disabled'); + + await waitForPromises(); + + expect(findDropdown().attributes('disabled')).toBeUndefined(); + }); + }); + }); + + 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().attributes('right')).toBe('true'); + }); + + it('sets the dropdown alignment to left on desktop', async () => { + jest.spyOn(bp, 'isDesktop').mockReturnValue(true); + createComponent(); + + await nextTick(); + + expect(findDropdown().attributes('right')).toBeUndefined(); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/utils_spec.js b/spec/frontend/vue_shared/components/members/utils_spec.js new file mode 100644 index 00000000000..f183abc08d6 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/utils_spec.js @@ -0,0 +1,29 @@ +import { generateBadges } from '~/vue_shared/components/members/utils'; +import { member as memberMock } from './mock_data'; + +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)); + }); + }); +}); |