summaryrefslogtreecommitdiff
path: root/spec/frontend/members
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/members')
-rw-r--r--spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js108
-rw-r--r--spec/frontend/members/components/action_buttons/approve_access_request_button_spec.js74
-rw-r--r--spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js85
-rw-r--r--spec/frontend/members/components/action_buttons/leave_button_spec.js59
-rw-r--r--spec/frontend/members/components/action_buttons/remove_group_link_button_spec.js64
-rw-r--r--spec/frontend/members/components/action_buttons/remove_member_button_spec.js66
-rw-r--r--spec/frontend/members/components/action_buttons/resend_invite_button_spec.js66
-rw-r--r--spec/frontend/members/components/action_buttons/user_action_buttons_spec.js89
-rw-r--r--spec/frontend/members/components/avatars/group_avatar_spec.js46
-rw-r--r--spec/frontend/members/components/avatars/invite_avatar_spec.js38
-rw-r--r--spec/frontend/members/components/avatars/user_avatar_spec.js115
-rw-r--r--spec/frontend/members/components/modals/leave_modal_spec.js91
-rw-r--r--spec/frontend/members/components/modals/remove_group_link_modal_spec.js106
-rw-r--r--spec/frontend/members/components/table/created_at_spec.js61
-rw-r--r--spec/frontend/members/components/table/expiration_datepicker_spec.js166
-rw-r--r--spec/frontend/members/components/table/expires_at_spec.js86
-rw-r--r--spec/frontend/members/components/table/member_action_buttons_spec.js43
-rw-r--r--spec/frontend/members/components/table/member_avatar_spec.js39
-rw-r--r--spec/frontend/members/components/table/member_source_spec.js71
-rw-r--r--spec/frontend/members/components/table/members_table_cell_spec.js251
-rw-r--r--spec/frontend/members/components/table/members_table_spec.js212
-rw-r--r--spec/frontend/members/components/table/role_dropdown_spec.js151
-rw-r--r--spec/frontend/members/mock_data.js71
-rw-r--r--spec/frontend/members/store/actions_spec.js152
-rw-r--r--spec/frontend/members/store/mutations_spec.js117
-rw-r--r--spec/frontend/members/store/utils_spec.js14
-rw-r--r--spec/frontend/members/utils_spec.js122
27 files changed, 2563 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/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..8ff1d2c1259
--- /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().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/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..a598b51d8dd
--- /dev/null
+++ b/spec/frontend/members/utils_spec.js
@@ -0,0 +1,122 @@
+import {
+ generateBadges,
+ isGroup,
+ isDirectMember,
+ isCurrentUser,
+ canRemove,
+ canResend,
+ canUpdate,
+ canOverride,
+} from '~/members/utils';
+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;
+
+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);
+ });
+ });
+});