Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/members/components/table')
-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
9 files changed, 1080 insertions, 0 deletions
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();
+ });
+});