diff options
Diffstat (limited to 'spec/frontend/members/components/table')
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..6c6abf35bd7 --- /dev/null +++ b/spec/frontend/members/components/table/role_dropdown_spec.js @@ -0,0 +1,151 @@ +import { mount, createWrapper, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { nextTick } from 'vue'; +import { within } from '@testing-library/dom'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import RoleDropdown from '~/members/components/table/role_dropdown.vue'; +import { member } from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('RoleDropdown', () => { + let wrapper; + let actions; + const $toast = { + show: jest.fn(), + }; + + const createStore = () => { + actions = { + updateMemberRole: jest.fn(() => Promise.resolve()), + }; + + return new Vuex.Store({ actions }); + }; + + const createComponent = (propsData = {}) => { + wrapper = mount(RoleDropdown, { + propsData: { + member, + permissions: {}, + ...propsData, + }, + localVue, + store: createStore(), + mocks: { + $toast, + }, + }); + }; + + const getDropdownMenu = () => within(wrapper.element).getByRole('menu'); + const getByTextInDropdownMenu = (text, options = {}) => + createWrapper(within(getDropdownMenu()).getByText(text, options)); + const getDropdownItemByText = text => + createWrapper( + within(getDropdownMenu()) + .getByText(text, { selector: '[role="menuitem"] p' }) + .closest('[role="menuitem"]'), + ); + const getCheckedDropdownItem = () => + wrapper + .findAll(GlDropdownItem) + .wrappers.find(dropdownItemWrapper => dropdownItemWrapper.props('isChecked')); + + const findDropdownToggle = () => wrapper.find('button[aria-haspopup="true"]'); + const findDropdown = () => wrapper.find(GlDropdown); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when dropdown is open', () => { + beforeEach(done => { + createComponent(); + + findDropdownToggle().trigger('click'); + wrapper.vm.$root.$on('bv::dropdown::shown', () => { + done(); + }); + }); + + it('renders all valid roles', () => { + Object.keys(member.validRoles).forEach(role => { + expect(getDropdownItemByText(role).exists()).toBe(true); + }); + }); + + it('renders dropdown header', () => { + expect(getByTextInDropdownMenu('Change permissions').exists()).toBe(true); + }); + + it('sets dropdown toggle and checks selected role', () => { + expect(findDropdownToggle().text()).toBe('Owner'); + expect(getCheckedDropdownItem().text()).toBe('Owner'); + }); + + describe('when dropdown item is selected', () => { + it('does nothing if the item selected was already selected', () => { + getDropdownItemByText('Owner').trigger('click'); + + expect(actions.updateMemberRole).not.toHaveBeenCalled(); + }); + + it('calls `updateMemberRole` Vuex action', () => { + getDropdownItemByText('Developer').trigger('click'); + + expect(actions.updateMemberRole).toHaveBeenCalledWith(expect.any(Object), { + memberId: member.id, + accessLevel: { integerValue: 30, stringValue: 'Developer' }, + }); + }); + + it('displays toast when successful', async () => { + getDropdownItemByText('Developer').trigger('click'); + + await waitForPromises(); + + expect($toast.show).toHaveBeenCalledWith('Role updated successfully.'); + }); + + it('disables dropdown while waiting for `updateMemberRole` to resolve', async () => { + getDropdownItemByText('Developer').trigger('click'); + + await nextTick(); + + expect(findDropdown().props('disabled')).toBe(true); + + await waitForPromises(); + + expect(findDropdown().props('disabled')).toBe(false); + }); + }); + }); + + it("sets initial dropdown toggle value to member's role", () => { + createComponent(); + + expect(findDropdownToggle().text()).toBe('Owner'); + }); + + it('sets the dropdown alignment to right on mobile', async () => { + jest.spyOn(bp, 'isDesktop').mockReturnValue(false); + createComponent(); + + await nextTick(); + + expect(findDropdown().props('right')).toBe(true); + }); + + it('sets the dropdown alignment to left on desktop', async () => { + jest.spyOn(bp, 'isDesktop').mockReturnValue(true); + createComponent(); + + await nextTick(); + + expect(findDropdown().props('right')).toBe(false); + }); +}); |