From b6da616fa51346d5bb0dc104caf7def7652dd623 Mon Sep 17 00:00:00 2001 From: Daniel Tian <dtian@gitlab.com> Date: Mon, 3 Jun 2024 14:00:56 +0000 Subject: [PATCH] Add ability to change member role to role details drawer --- .../components/table/members_table.vue | 16 +- .../components/table/role_details_drawer.vue | 282 ++++++++++++----- .../components/table/role_selector.vue | 71 +++++ app/assets/javascripts/members/utils.js | 10 +- ee/app/assets/javascripts/members/utils.js | 34 +- .../members/components/table/max_role_spec.js | 2 +- .../table/role_details_drawer_spec.js | 135 ++++++++ .../components/table/role_selector_spec.js | 64 ++++ ee/spec/frontend/members/mock_data.js | 29 +- ee/spec/frontend/members/utils_spec.js | 15 +- locale/gitlab.pot | 12 + .../components/table/members_table_spec.js | 63 ++-- .../table/role_details_drawer_spec.js | 295 ++++++++++++++---- .../components/table/role_selector_spec.js | 78 +++++ spec/frontend/members/mock_data.js | 22 +- spec/frontend/members/utils_spec.js | 2 +- 16 files changed, 907 insertions(+), 223 deletions(-) create mode 100644 app/assets/javascripts/members/components/table/role_selector.vue create mode 100644 ee/spec/frontend/members/components/table/role_details_drawer_spec.js create mode 100644 ee/spec/frontend/members/components/table/role_selector_spec.js create mode 100644 spec/frontend/members/components/table/role_selector_spec.js diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index 8327e5e56a5a5..540d55990aae6 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -1,5 +1,5 @@ <script> -import { GlTable, GlBadge, GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { GlTable, GlBadge, GlTooltipDirective, GlButton } from '@gitlab/ui'; // eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import MembersTableCell from 'ee_else_ce/members/components/table/members_table_cell.vue'; @@ -39,7 +39,7 @@ export default { components: { GlTable, GlBadge, - GlLink, + GlButton, MemberAvatar, CreatedAt, MembersTableCell, @@ -72,6 +72,7 @@ export default { data() { return { selectedMember: null, + isRoleDrawerBusy: false, }; }, computed: { @@ -88,6 +89,9 @@ export default { pagination(state) { return state[this.namespace].pagination; }, + memberPath(state) { + return state[this.namespace].memberPath; + }, }), filteredAndModifiedFields() { return FIELDS.filter( @@ -286,13 +290,15 @@ export default { <template #cell(maxRole)="{ item: member }"> <members-table-cell #default="{ permissions }" :member="member" data-testid="max-role"> <div v-if="glFeatures.showRoleDetailsInDrawer"> - <gl-link + <gl-button v-gl-tooltip.d0.hover="member.accessLevel.description" + variant="link" + :disabled="isRoleDrawerBusy" class="gl-display-block" @click="selectedMember = member" > {{ member.accessLevel.stringValue }} - </gl-link> + </gl-button> <gl-badge v-if="member.accessLevel.memberRoleId" class="gl-mt-3" size="sm"> {{ s__('MemberRole|Custom role') }} </gl-badge> @@ -335,6 +341,8 @@ export default { <role-details-drawer v-if="glFeatures.showRoleDetailsInDrawer" :member="selectedMember" + :member-path="memberPath" + @busy="isRoleDrawerBusy = $event" @close="selectedMember = null" /> </div> diff --git a/app/assets/javascripts/members/components/table/role_details_drawer.vue b/app/assets/javascripts/members/components/table/role_details_drawer.vue index d824a9a922ce1..3c8a74de68478 100644 --- a/app/assets/javascripts/members/components/table/role_details_drawer.vue +++ b/app/assets/javascripts/members/components/table/role_details_drawer.vue @@ -1,11 +1,28 @@ <script> -import { GlDrawer, GlBadge, GlSprintf, GlButton, GlIcon } from '@gitlab/ui'; +import { GlDrawer, GlBadge, GlSprintf, GlButton, GlIcon, GlAlert } from '@gitlab/ui'; import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; import { helpPagePath } from '~/helpers/help_page_helper'; import MembersTableCell from 'ee_else_ce/members/components/table/members_table_cell.vue'; import { ACCESS_LEVEL_LABELS } from '~/access_level/constants'; +import axios from '~/lib/utils/axios_utils'; +import { s__ } from '~/locale'; +import { roleDropdownItems } from 'ee_else_ce/members/utils'; +import { + GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME, + MEMBER_ACCESS_LEVEL_PROPERTY_NAME, + MEMBERS_TAB_TYPES, +} from '~/members/constants'; +import * as Sentry from '~/ci/runner/sentry_utils'; import MemberAvatar from './member_avatar.vue'; +import RoleSelector from './role_selector.vue'; + +// The API to update members uses different property names for the access level, depending on if it's a user or a group. +// Users use 'access_level', groups use 'group_access'. +const ACCESS_LEVEL_PROPERTY_NAME = { + [MEMBERS_TAB_TYPES.user]: MEMBER_ACCESS_LEVEL_PROPERTY_NAME, + [MEMBERS_TAB_TYPES.group]: GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME, +}; export default { components: { @@ -16,116 +33,233 @@ export default { GlSprintf, GlButton, GlIcon, + GlAlert, + RoleSelector, }, + inject: ['namespace'], props: { member: { type: Object, required: false, default: null, }, + memberPath: { + type: String, + required: true, + }, + }, + data() { + return { + selectedRole: null, + isSavingRole: false, + saveError: null, + }; }, computed: { - viewPermissionsDocPath() { - return helpPagePath('user/permissions'); + roles() { + return roleDropdownItems(this.member); + }, + initialRole() { + const { memberRoleId = null, integerValue, stringValue } = this.member.accessLevel; + const role = this.roles.flatten.find( + (r) => r.memberRoleId === memberRoleId && r.accessLevel === integerValue, + ); + // When the user doesn't have write access to the members list, the members data won't have custom roles. If the + // member is assigned a custom role, there won't be an entry for it in the custom roles list, and the role name + // won't be shown. To fix this, we'll return a fake role with just the role name and member role ID so that the + // role name will be shown properly. + return role || { text: stringValue, memberRoleId }; + }, + }, + watch: { + 'member.accessLevel': { + immediate: true, + handler() { + if (this.member) { + this.selectedRole = this.initialRole; + } + }, }, - customRole() { - const customRoleId = this.member.accessLevel.memberRoleId; + isSavingRole() { + this.$emit('busy', this.isSavingRole); + }, + selectedRole() { + this.saveError = null; + }, + }, + methods: { + closeDrawer() { + // Don't close the drawer if the role API call is still underway. + if (!this.isSavingRole) { + this.$emit('close'); + } + }, + async updateRole() { + try { + this.saveError = null; + this.isSavingRole = true; - return this.member.customRoles?.find(({ memberRoleId }) => memberRoleId === customRoleId); + const url = this.memberPath.replace(':id', this.member.id); + const accessLevelProp = ACCESS_LEVEL_PROPERTY_NAME[this.namespace]; + + const { data } = await axios.put(url, { + [accessLevelProp]: this.selectedRole.accessLevel, + member_role_id: this.selectedRole.memberRoleId, + }); + + // EE has a flow where the role is not changed immediately, but goes through an approval process. In that case + // we need to restore the role back to what the member had initially. + if (data?.enqueued) { + this.$toast.show(s__('Members|Role change request was sent to the administrator.')); + this.resetRole(); + } else { + this.$toast.show(s__('Members|Role was successfully updated.')); + const { member } = this; + // Update the access level on the member object so that the members table shows the new role. + member.accessLevel = { + ...this.selectedRole, + stringValue: this.selectedRole.text, + integerValue: this.selectedRole.accessLevel, + }; + } + } catch (error) { + this.saveError = s__('MemberRole|Could not update role.'); + Sentry.captureException(error); + } finally { + this.isSavingRole = false; + } }, - customRolePermissions() { - return this.customRole?.permissions || []; + resetRole() { + this.selectedRole = this.initialRole; }, }, getContentWrapperHeight, + helpPagePath, DRAWER_Z_INDEX, ACCESS_LEVEL_LABELS, }; </script> <template> - <gl-drawer + <members-table-cell v-if="member" - :header-height="$options.getContentWrapperHeight()" - header-sticky - :z-index="$options.DRAWER_Z_INDEX" - open - @close="$emit('close')" + #default="{ memberType, isCurrentUser, permissions }" + :member="member" > - <template #title> - <h4 class="gl-m-0">{{ s__('MemberRole|Role details') }}</h4> - </template> + <gl-drawer + :header-height="$options.getContentWrapperHeight()" + header-sticky + :z-index="$options.DRAWER_Z_INDEX" + open + @close="closeDrawer" + > + <template #title> + <h4 class="gl-m-0">{{ s__('MemberRole|Role details') }}</h4> + </template> + + <!-- Do not remove this div, it's needed because every top-level element in the drawer's body will get padding and + a bottom border applied to it, and we don't want that to be applied to everything. --> + <div> + <h5 class="gl-mr-6">{{ __('Account') }}</h5> - <!-- Do not remove this div, it's needed because every top-level element in the drawer's body will get padding and a - bottom border applied to it, and we don't want that to be applied to everything. --> - <div> - <h5 class="gl-mr-6">{{ __('Account') }}</h5> - <members-table-cell #default="{ memberType, isCurrentUser }" :member="member"> <member-avatar :member-type="memberType" :is-current-user="isCurrentUser" :member="member" /> - </members-table-cell> - <hr /> + <hr /> - <dl> - <dt class="gl-mb-3" data-testid="role-header">{{ s__('MemberRole|Role') }}</dt> - <dd data-testid="role-value"> - {{ member.accessLevel.stringValue }} - <gl-badge v-if="customRole" size="sm" class="gl-ml-2"> - {{ s__('MemberRole|Custom role') }} - </gl-badge> - </dd> + <dl> + <dt class="gl-mb-3" data-testid="role-header">{{ s__('MemberRole|Role') }}</dt> + <dd> + <role-selector + v-if="permissions.canUpdate" + v-model="selectedRole" + :roles="roles" + :loading="isSavingRole" + class="gl-mb-3" + /> + <span v-else class="gl-mr-1" data-testid="role-text">{{ selectedRole.text }}</span> - <template v-if="customRole"> - <dt class="gl-mt-6 gl-mb-3" data-testid="description-header"> - {{ s__('MemberRole|Description') }} + <gl-badge v-if="selectedRole.memberRoleId" size="sm"> + {{ s__('MemberRole|Custom role') }} + </gl-badge> + </dd> + + <template v-if="selectedRole.description"> + <dt class="gl-mt-6 gl-mb-3" data-testid="description-header"> + {{ s__('MemberRole|Description') }} + </dt> + <dd data-testid="description-value"> + {{ selectedRole.description }} + </dd> + </template> + + <dt class="gl-mt-6 gl-mb-3" data-testid="permissions-header"> + {{ s__('MemberRole|Permissions') }} </dt> - <dd data-testid="description-value"> - {{ member.accessLevel.description }} + <dd class="gl-display-flex gl-mb-5"> + <span v-if="selectedRole.permissions" class="gl-mr-3" data-testid="base-role"> + <gl-sprintf :message="s__('MemberRole|Base role: %{role}')"> + <template #role> + {{ $options.ACCESS_LEVEL_LABELS[selectedRole.accessLevel] }} + </template> + </gl-sprintf> + </span> + <gl-button + :href="$options.helpPagePath('user/permissions')" + icon="external-link" + variant="link" + target="_blank" + data-testid="view-permissions-button" + > + {{ s__('MemberRole|View permissions') }} + </gl-button> </dd> - </template> - <dt class="gl-mt-6 gl-mb-3" data-testid="permissions-header"> - {{ s__('MemberRole|Permissions') }} - </dt> - <dd class="gl-display-flex gl-mb-5"> - <span v-if="customRole" class="gl-mr-3" data-testid="base-role"> - <gl-sprintf :message="s__('MemberRole|Base role: %{role}')"> - <template #role> - {{ $options.ACCESS_LEVEL_LABELS[customRole.baseAccessLevel] }} - </template> - </gl-sprintf> - </span> + <div + v-for="permission in selectedRole.permissions" + :key="permission.name" + class="gl-display-flex" + data-testid="permission" + > + <gl-icon name="check" class="gl-flex-shrink-0" /> + <div class="gl-mx-3"> + <span data-testid="permission-name"> + {{ permission.name }} + </span> + <p class="gl-mt-2 gl-text-secondary" data-testid="permission-description"> + {{ permission.description }} + </p> + </div> + </div> + </dl> + </div> + + <template #footer> + <div v-if="selectedRole !== initialRole"> + <gl-alert v-if="saveError" class="gl-mb-5" variant="danger" :dismissible="false"> + {{ saveError }} + </gl-alert> <gl-button - :href="viewPermissionsDocPath" - icon="external-link" - variant="link" - target="_blank" + variant="confirm" + :loading="isSavingRole" + data-testid="save-button" + @click="updateRole" > - {{ s__('MemberRole|View permissions') }} + {{ s__('MemberRole|Update role') }} + </gl-button> + <gl-button + class="gl-ml-2" + :disabled="isSavingRole" + data-testid="cancel-button" + @click="resetRole" + > + {{ __('Cancel') }} </gl-button> - </dd> - - <div - v-for="permission in customRolePermissions" - :key="permission.name" - class="gl-display-flex" - data-testid="permission" - > - <gl-icon name="check" class="gl-flex-shrink-0" /> - <div class="gl-mx-3"> - <span data-testid="permission-name"> - {{ permission.name }} - </span> - <p class="gl-mt-2 gl-text-secondary" data-testid="permission-description"> - {{ permission.description }} - </p> - </div> </div> - </dl> - </div> - </gl-drawer> + </template> + </gl-drawer> + </members-table-cell> </template> diff --git a/app/assets/javascripts/members/components/table/role_selector.vue b/app/assets/javascripts/members/components/table/role_selector.vue new file mode 100644 index 0000000000000..bf8ea5c29be87 --- /dev/null +++ b/app/assets/javascripts/members/components/table/role_selector.vue @@ -0,0 +1,71 @@ +<script> +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { visitUrl } from '~/lib/utils/url_utility'; + +export default { + components: { GlCollapsibleListbox }, + inject: { + manageMemberRolesPath: { default: null }, + }, + props: { + roles: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + manageRolesText() { + return this.manageMemberRolesPath ? s__('MemberRole|Manage roles') : ''; + }, + }, + methods: { + navigateToManageMemberRolesPage() { + visitUrl(this.manageMemberRolesPath); + }, + emitRole(selectedValue) { + const role = this.roles.flatten.find(({ value }) => value === selectedValue); + this.$emit('input', role); + }, + }, +}; +</script> + +<template> + <gl-collapsible-listbox + :header-text="s__('MemberRole|Change role')" + :reset-button-label="manageRolesText" + :items="roles.formatted" + :selected="value.value" + :loading="loading" + block + @reset="navigateToManageMemberRolesPage" + @select="emitRole" + > + <template #list-item="{ item }"> + <div + class="gl-line-clamp-2" + :class="{ 'gl-font-weight-bold': item.memberRoleId }" + data-testid="role-name" + > + {{ item.text }} + </div> + <div + v-if="item.description" + class="gl-text-gray-700 gl-font-sm gl-mt-1 gl-line-clamp-2" + data-testid="role-description" + > + {{ item.description }} + </div> + </template> + </gl-collapsible-listbox> +</template> diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js index cdc648e7d17df..8c1de5dad34f5 100644 --- a/app/assets/javascripts/members/utils.js +++ b/app/assets/javascripts/members/utils.js @@ -1,4 +1,4 @@ -import { isUndefined, uniqueId } from 'lodash'; +import { isUndefined } from 'lodash'; import { s__ } from '~/locale'; import showGlobalToast from '~/vue_shared/plugins/global_toast'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; @@ -44,11 +44,11 @@ export const generateBadges = ({ member, isCurrentUser, canManageMembers }) => [ * @param {Map<string, number>} member.validRoles */ export const roleDropdownItems = ({ validRoles }) => { - const staticRoleDropdownItems = Object.entries(validRoles).map(([name, value]) => ({ - accessLevel: value, + const staticRoleDropdownItems = Object.entries(validRoles).map(([text, accessLevel]) => ({ + text, + accessLevel, memberRoleId: null, // The value `null` is need to downgrade from custom role to static role. See: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/133430#note_1595153555 - text: name, - value: uniqueId('role-static-'), + value: `role-static-${accessLevel}`, })); return { flatten: staticRoleDropdownItems, formatted: staticRoleDropdownItems }; diff --git a/ee/app/assets/javascripts/members/utils.js b/ee/app/assets/javascripts/members/utils.js index c40b42d09a6b5..f33260d6e47d8 100644 --- a/ee/app/assets/javascripts/members/utils.js +++ b/ee/app/assets/javascripts/members/utils.js @@ -1,4 +1,3 @@ -import { uniqueId } from 'lodash'; import showGlobalToast from '~/vue_shared/plugins/global_toast'; import { __, s__ } from '~/locale'; import { @@ -55,33 +54,24 @@ export const generateBadges = ({ member, isCurrentUser, canManageMembers }) => [ * @param {Array<{baseAccessLevel: number, name: string, memberRoleId: number}>} member.customRoles */ export const roleDropdownItems = ({ validRoles, customRoles }) => { + const standardDropdown = CERoleDropdownItems({ validRoles }); + if (!customRoles?.length) { - return CERoleDropdownItems({ validRoles }); + return standardDropdown; } - const { flatten: staticRoleDropdownItems } = CERoleDropdownItems({ validRoles }); - - const customRoleDropdownItems = customRoles.map( - ({ baseAccessLevel, name, memberRoleId, description }) => ({ - accessLevel: baseAccessLevel, - memberRoleId, - text: name, - value: uniqueId('role-custom-'), - description, - }), - ); + const customRoleItems = customRoles.map(({ baseAccessLevel, name, ...role }) => ({ + ...role, + accessLevel: baseAccessLevel, + text: name, + value: `role-custom-${role.memberRoleId}`, + })); return { - flatten: [...staticRoleDropdownItems, ...customRoleDropdownItems], + flatten: [...standardDropdown.flatten, ...customRoleItems], formatted: [ - { - text: s__('MemberRole|Standard roles'), - options: staticRoleDropdownItems, - }, - { - text: s__('MemberRole|Custom roles'), - options: customRoleDropdownItems, - }, + { text: s__('MemberRole|Standard roles'), options: standardDropdown.flatten }, + { text: s__('MemberRole|Custom roles'), options: customRoleItems }, ], }; }; diff --git a/ee/spec/frontend/members/components/table/max_role_spec.js b/ee/spec/frontend/members/components/table/max_role_spec.js index ee796d802e715..bc2abf3473d2a 100644 --- a/ee/spec/frontend/members/components/table/max_role_spec.js +++ b/ee/spec/frontend/members/components/table/max_role_spec.js @@ -65,7 +65,7 @@ describe('MaxRole', () => { const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); const findListboxItems = () => wrapper.findAllComponents(GlListboxItem); const findListboxItemByText = (text) => - findListboxItems().wrappers.find((item) => item.text() === text); + findListboxItems().wrappers.find((item) => item.text().includes(text)); const findRoleText = () => wrapper.findByTestId('role-text'); describe('when a member has custom permissions', () => { diff --git a/ee/spec/frontend/members/components/table/role_details_drawer_spec.js b/ee/spec/frontend/members/components/table/role_details_drawer_spec.js new file mode 100644 index 0000000000000..11314e4721ae2 --- /dev/null +++ b/ee/spec/frontend/members/components/table/role_details_drawer_spec.js @@ -0,0 +1,135 @@ +import { GlDrawer, GlBadge, GlSprintf, GlIcon } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RoleDetailsDrawer from '~/members/components/table/role_details_drawer.vue'; +import MembersTableCell from '~/members/components/table/members_table_cell.vue'; +import RoleSelector from '~/members/components/table/role_selector.vue'; +import { roleDropdownItems } from 'ee/members/utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import { member as baseRoleMember, updateableCustomRoleMember } from '../../mock_data'; + +describe('Role details drawer', () => { + const { permissions } = updateableCustomRoleMember.customRoles[1]; + const customRole = roleDropdownItems(updateableCustomRoleMember).flatten[8]; + let axiosMock; + let wrapper; + + const createWrapper = ({ member = updateableCustomRoleMember, namespace = 'user' } = {}) => { + wrapper = shallowMountExtended(RoleDetailsDrawer, { + propsData: { member, memberPath: 'user/path/:id' }, + provide: { + currentUserId: 1, + canManageMembers: true, + namespace, + }, + stubs: { GlDrawer, MembersTableCell, GlSprintf }, + }); + }; + + const findRoleSelector = () => wrapper.findComponent(RoleSelector); + const findCustomRoleBadge = () => wrapper.findComponent(GlBadge); + const findDescriptionHeader = () => wrapper.findByTestId('description-header'); + const findDescriptionValue = () => wrapper.findByTestId('description-value'); + const findBaseRole = () => wrapper.findByTestId('base-role'); + const findPermissions = () => wrapper.findAllByTestId('permission'); + const findPermissionAt = (index) => findPermissions().at(index); + const findPermissionNameAt = (index) => wrapper.findAllByTestId('permission-name').at(index); + const findPermissionDescriptionAt = (index) => + wrapper.findAllByTestId('permission-description').at(index); + const findSaveButton = () => wrapper.findByTestId('save-button'); + + const createWrapperChangeRoleAndClickSave = async () => { + createWrapper({ member: updateableCustomRoleMember }); + findRoleSelector().vm.$emit('input', customRole); + await nextTick(); + findSaveButton().vm.$emit('click'); + + return waitForPromises(); + }; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + }); + + afterEach(() => { + axiosMock.restore(); + }); + + describe('when the member has a base role', () => { + beforeEach(() => { + createWrapper({ member: baseRoleMember }); + }); + + it('does not show the custom role badge', () => { + expect(findCustomRoleBadge().exists()).toBe(false); + }); + + it('does not show the role description', () => { + expect(findDescriptionHeader().exists()).toBe(false); + expect(findDescriptionValue().exists()).toBe(false); + }); + + it('does not show the base role in the permissions section', () => { + expect(findBaseRole().exists()).toBe(false); + }); + + it('does not show any permissions', () => { + expect(findPermissions()).toHaveLength(0); + }); + }); + + describe('when the member has a custom role', () => { + beforeEach(() => { + createWrapper(); + }); + + it('shows the custom role badge', () => { + expect(findCustomRoleBadge().props('size')).toBe('sm'); + expect(findCustomRoleBadge().text()).toBe('Custom role'); + }); + + it('shows the role description', () => { + expect(findDescriptionHeader().text()).toBe('Description'); + expect(findDescriptionValue().text()).toBe('custom role 1 description'); + }); + + it('shows the base role in the permissions section', () => { + expect(findBaseRole().text()).toMatchInterpolatedText('Base role: Guest'); + }); + + it('shows the expected number of permissions', () => { + expect(findPermissions()).toHaveLength(2); + }); + + describe.each(permissions)(`for permission '$name'`, (permission) => { + const index = permissions.indexOf(permission); + + it('shows the check icon', () => { + expect(findPermissionAt(index).findComponent(GlIcon).props('name')).toBe('check'); + }); + + it('shows the permission name', () => { + expect(findPermissionNameAt(index).text()).toBe(`Permission ${index}`); + }); + + it('shows the permission description', () => { + expect(findPermissionDescriptionAt(index).text()).toBe(`Permission description ${index}`); + }); + }); + }); + + describe('when update role button is clicked for a custom role', () => { + beforeEach(() => { + axiosMock.onPut('user/path/238').replyOnce(200); + return createWrapperChangeRoleAndClickSave(); + }); + + it('calls update role API with expected data', () => { + const expectedData = JSON.stringify({ access_level: 10, member_role_id: 102 }); + + expect(axiosMock.history.put[0].data).toBe(expectedData); + }); + }); +}); diff --git a/ee/spec/frontend/members/components/table/role_selector_spec.js b/ee/spec/frontend/members/components/table/role_selector_spec.js new file mode 100644 index 0000000000000..6d6f3556d6eed --- /dev/null +++ b/ee/spec/frontend/members/components/table/role_selector_spec.js @@ -0,0 +1,64 @@ +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { roleDropdownItems } from 'ee/members/utils'; +import RoleSelector from '~/members/components/table/role_selector.vue'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { upgradedMember } from '../../mock_data'; + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + visitUrl: jest.fn(), +})); + +describe('Role selector', () => { + const dropdownItems = roleDropdownItems(upgradedMember); + let wrapper; + + const createWrapper = ({ + roles = dropdownItems, + value = dropdownItems.flatten[0], + loading, + } = {}) => { + wrapper = mountExtended(RoleSelector, { + propsData: { roles, value, loading }, + provide: { manageMemberRolesPath: 'path' }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); + const getDropdownItem = (id) => wrapper.findByTestId(`listbox-item-${id}`); + const findRoleDescription = (id) => getDropdownItem(id).find('[data-testid="role-description"]'); + + beforeEach(() => { + createWrapper(); + }); + + describe('role description', () => { + it.each(dropdownItems.formatted[0].options)( + 'does not show description for base role $text', + ({ value }) => { + expect(findRoleDescription(value).exists()).toBe(false); + }, + ); + + it.each(dropdownItems.formatted[1].options)( + 'shows the role description for custom role $text', + ({ value, description }) => { + expect(findRoleDescription(value).text()).toBe(description); + }, + ); + }); + + describe('manage roles link', () => { + it('shows manage role link when there is a manageMemberRolesPath', () => { + expect(findDropdown().props('resetButtonLabel')).toBe('Manage roles'); + }); + + it('opens manageMemberRolesPath in a new tab when the link is clicked', () => { + findDropdown().vm.$emit('reset'); + + expect(visitUrl).toHaveBeenCalledTimes(1); + expect(visitUrl).toHaveBeenCalledWith('path'); + }); + }); +}); diff --git a/ee/spec/frontend/members/mock_data.js b/ee/spec/frontend/members/mock_data.js index e2fb647f1cedb..4116abf188fdb 100644 --- a/ee/spec/frontend/members/mock_data.js +++ b/ee/spec/frontend/members/mock_data.js @@ -14,14 +14,33 @@ export const bannedMember = { }; export const customRoles = [ - { baseAccessLevel: 20, name: 'custom role 3', memberRoleId: 103 }, + { + baseAccessLevel: 20, + name: 'custom role 3', + memberRoleId: 103, + description: 'custom role 3 description', + permissions: [{ name: 'Permission 4', description: 'Permission description 4' }], + }, { baseAccessLevel: 10, name: 'custom role 1', description: 'custom role 1 description', memberRoleId: 101, + permissions: [ + { name: 'Permission 0', description: 'Permission description 0' }, + { name: 'Permission 1', description: 'Permission description 1' }, + ], + }, + { + baseAccessLevel: 10, + name: 'custom role 2', + description: 'custom role 2 description', + memberRoleId: 102, + permissions: [ + { name: 'Permission 2', description: 'Permission description 2' }, + { name: 'Permission 3', description: 'Permission description 3' }, + ], }, - { baseAccessLevel: 10, name: 'custom role 2', memberRoleId: 102 }, ]; export const upgradedMember = { @@ -35,6 +54,12 @@ export const upgradedMember = { customRoles, }; +export const updateableCustomRoleMember = { + ...upgradedMember, + isDirectMember: true, + canUpdate: true, +}; + // eslint-disable-next-line import/export export const dataAttribute = JSON.stringify({ ...JSON.parse(CEDataAttribute), diff --git a/ee/spec/frontend/members/utils_spec.js b/ee/spec/frontend/members/utils_spec.js index 3d51d24e5b8df..bab545aae398d 100644 --- a/ee/spec/frontend/members/utils_spec.js +++ b/ee/spec/frontend/members/utils_spec.js @@ -107,12 +107,15 @@ describe('Members Utils', () => { const { flatten } = roleDropdownItems({ ...memberMock, customRoles }); expect(flatten).toHaveLength(3); - expect(flatten).toContainEqual({ - text: 'custom role 1', - value: 'role-custom-0', - description: 'custom role 1 description', - accessLevel: 10, - memberRoleId: 101, + customRoles.forEach((role) => { + expect(flatten).toContainEqual({ + text: role.name, + value: `role-custom-${role.memberRoleId}`, + description: role.description, + accessLevel: role.baseAccessLevel, + memberRoleId: role.memberRoleId, + permissions: role.permissions, + }); }); }); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 6ae8a366bc609..17000272a934c 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -31801,9 +31801,15 @@ msgstr "" msgid "MemberRole|Cannot create a member role with no enabled permissions" msgstr "" +msgid "MemberRole|Change role" +msgstr "" + msgid "MemberRole|Could not fetch available permissions." msgstr "" +msgid "MemberRole|Could not update role." +msgstr "" + msgid "MemberRole|Create a custom role with specific abilities by starting with a base role and adding custom permissions. %{linkStart}Learn more about custom roles.%{linkEnd}" msgstr "" @@ -31915,6 +31921,9 @@ msgstr "" msgid "MemberRole|To delete custom role, remove role from all group members." msgstr "" +msgid "MemberRole|Update role" +msgstr "" + msgid "MemberRole|View permissions" msgstr "" @@ -32131,6 +32140,9 @@ msgstr "" msgid "Members|Role updated successfully." msgstr "" +msgid "Members|Role was successfully updated." +msgstr "" + msgid "Members|Search groups" msgstr "" diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js index 2cdf152b7f391..765625fd8ddbc 100644 --- a/spec/frontend/members/components/table/members_table_spec.js +++ b/spec/frontend/members/components/table/members_table_spec.js @@ -1,4 +1,4 @@ -import { GlTable, GlLink, GlBadge } from '@gitlab/ui'; +import { GlTable, GlButton, GlBadge } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; // eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; @@ -46,6 +46,7 @@ describe('MembersTable', () => { namespaced: true, state: { members: [], + memberPath: 'invite/path/:id', tableFields: [], tableAttrs: { tr: { 'data-testid': 'member-row' }, @@ -78,13 +79,14 @@ describe('MembersTable', () => { RemoveMemberModal: true, MemberActions: true, MaxRole: true, + RoleDetailsDrawer: true, }, }); }; const findTable = () => wrapper.findComponent(GlTable); const findRoleDetailsDrawer = () => wrapper.findComponent(RoleDetailsDrawer); - const findMaxRoleLink = () => wrapper.findByTestId('max-role').findComponent(GlLink); + const findRoleButton = () => wrapper.findComponent(GlButton); const findCustomRoleBadge = () => wrapper.findByTestId('max-role').findComponent(GlBadge); const findTableCellByMemberId = (tableCellLabel, memberId) => wrapper @@ -128,25 +130,41 @@ describe('MembersTable', () => { createComponent({ members: [member], tableFields: ['maxRole'] }); }; - it('shows the max role link', () => { + it('shows the role button', () => { createMaxRoleComponent(); - expect(findMaxRoleLink().text()).toBe('Owner'); + expect(findRoleButton().text()).toBe('Owner'); }); - it('does not show the "Custom role" badge', () => { - createMaxRoleComponent(); + describe('custom role badge', () => { + it('shows the badge for a custom role', () => { + const member = cloneDeep(memberMock); + member.accessLevel.memberRoleId = 1; + createMaxRoleComponent(member); - expect(findCustomRoleBadge().exists()).toBe(false); - }); + expect(findCustomRoleBadge().props('size')).toBe('sm'); + expect(findCustomRoleBadge().text()).toBe('Custom role'); + }); - it('shows the "Custom role" badge', () => { - const member = cloneDeep(memberMock); - member.accessLevel.memberRoleId = 1; - createMaxRoleComponent(member); + it('does not show badge for a standard role', () => { + createMaxRoleComponent(); + + expect(findCustomRoleBadge().exists()).toBe(false); + }); + }); - expect(findCustomRoleBadge().props('size')).toBe('sm'); - expect(findCustomRoleBadge().text()).toBe('Custom role'); + describe('disabled state', () => { + it.each` + phrase | busy + ${'disables'} | ${true} + ${'enables'} | ${false} + `('$phrase the button when the drawer busy state is $busy', async ({ busy }) => { + createMaxRoleComponent(); + findRoleDetailsDrawer().vm.$emit('busy', busy); + await nextTick(); + + expect(findRoleButton().props('disabled')).toBe(busy); + }); }); }); @@ -294,13 +312,13 @@ describe('MembersTable', () => { }); describe('role details drawer', () => { - it('shows role details drawer', () => { + it('creates role details drawer with no member selected', () => { createComponent(); - // Drawer should start off with no member passed to it. + expect(findRoleDetailsDrawer().props('member')).toBe(null); }); - it('does not show role details drawer if showRoleDetailsInDrawer feature flag is off', () => { + it('does not show drawer if showRoleDetailsInDrawer feature flag is off', () => { createComponent(null, { showRoleDetailsInDrawer: false }); expect(findRoleDetailsDrawer().exists()).toBe(false); @@ -309,7 +327,7 @@ describe('MembersTable', () => { describe('with member selected', () => { beforeEach(() => { createComponent({ members: [memberMock], tableFields: ['maxRole'] }); - return findMaxRoleLink().trigger('click'); + return findRoleButton().trigger('click'); }); it('passes member to drawer', () => { @@ -317,11 +335,18 @@ describe('MembersTable', () => { }); it('clears member when drawer is closed', async () => { - await findRoleDetailsDrawer().vm.$emit('close'); + findRoleDetailsDrawer().vm.$emit('close'); await nextTick(); expect(findRoleDetailsDrawer().props('member')).toBe(null); }); + + it('disables role button when drawer is busy', async () => { + findRoleDetailsDrawer().vm.$emit('busy', true); + await nextTick(); + + expect(findRoleButton().props('disabled')).toBe(true); + }); }); }); diff --git a/spec/frontend/members/components/table/role_details_drawer_spec.js b/spec/frontend/members/components/table/role_details_drawer_spec.js index 8cac9a2a65a92..300c3853e46e9 100644 --- a/spec/frontend/members/components/table/role_details_drawer_spec.js +++ b/spec/frontend/members/components/table/role_details_drawer_spec.js @@ -1,46 +1,70 @@ -import { GlDrawer, GlButton, GlBadge, GlSprintf, GlIcon } from '@gitlab/ui'; +import { GlDrawer, GlSprintf, GlAlert } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; +import { nextTick } from 'vue'; +import { cloneDeep } from 'lodash'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import RoleDetailsDrawer from '~/members/components/table/role_details_drawer.vue'; import MembersTableCell from '~/members/components/table/members_table_cell.vue'; import MemberAvatar from '~/members/components/table/member_avatar.vue'; -import { member as memberData, memberWithCustomRole } from '../../mock_data'; +import RoleSelector from '~/members/components/table/role_selector.vue'; +import { roleDropdownItems } from '~/members/utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import { member as memberData, updateableMember } from '../../mock_data'; describe('Role details drawer', () => { - const { permissions } = memberWithCustomRole.customRoles[0]; + const dropdownItems = roleDropdownItems(updateableMember); + const toastShowMock = jest.fn(); + const role1 = dropdownItems.flatten[4]; + const role2 = dropdownItems.flatten[2]; + let axiosMock; let wrapper; - const createWrapper = ({ member } = {}) => { + const createWrapper = ({ member = updateableMember, namespace = 'user' } = {}) => { wrapper = shallowMountExtended(RoleDetailsDrawer, { - propsData: { member }, - provide: { currentUserId: memberData.user.id, canManageMembers: false }, - stubs: { MembersTableCell, GlSprintf }, + propsData: { member, memberPath: 'user/path/:id' }, + provide: { + currentUserId: 1, + canManageMembers: true, + namespace, + }, + stubs: { GlDrawer, MembersTableCell, GlSprintf }, + mocks: { $toast: { show: toastShowMock } }, }); }; const findDrawer = () => wrapper.findComponent(GlDrawer); - const findCustomRoleBadge = () => wrapper.findComponent(GlBadge); - const findDescriptionHeader = () => wrapper.findByTestId('description-header'); - const findDescriptionValue = () => wrapper.findByTestId('description-value'); - const findBaseRole = () => wrapper.findByTestId('base-role'); - const findPermissions = () => wrapper.findAllByTestId('permission'); - const findPermissionAt = (index) => findPermissions().at(index); - const findPermissionNameAt = (index) => wrapper.findAllByTestId('permission-name').at(index); - const findPermissionDescriptionAt = (index) => - wrapper.findAllByTestId('permission-description').at(index); - - it('does not show the drawer when there is no member selected', () => { - createWrapper(); + const findRoleText = () => wrapper.findByTestId('role-text'); + const findRoleSelector = () => wrapper.findComponent(RoleSelector); + const findSaveButton = () => wrapper.findByTestId('save-button'); + const findCancelButton = () => wrapper.findByTestId('cancel-button'); + + const createWrapperChangeRoleAndClickSave = async () => { + createWrapper({ member: cloneDeep(updateableMember) }); + findRoleSelector().vm.$emit('input', role2); + await nextTick(); + findSaveButton().vm.$emit('click'); + + return waitForPromises(); + }; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + }); + + afterEach(() => { + axiosMock.restore(); + }); + + it('does not show the drawer when there is no member', () => { + createWrapper({ member: null }); expect(findDrawer().exists()).toBe(false); }); - describe.each` - roleName | member - ${'base role'} | ${memberData} - ${'custom role'} | ${memberWithCustomRole} - `(`when there is a member (common tests for $roleName)`, ({ member }) => { + describe('when there is a member', () => { beforeEach(() => { - createWrapper({ member }); + createWrapper({ member: memberData }); }); it('shows the drawer with expected props', () => { @@ -48,21 +72,32 @@ describe('Role details drawer', () => { }); it('shows the user avatar', () => { - expect(wrapper.findComponent(MembersTableCell).props('member')).toBe(member); + expect(wrapper.findComponent(MembersTableCell).props('member')).toBe(memberData); expect(wrapper.findComponent(MemberAvatar).props()).toMatchObject({ memberType: 'user', - isCurrentUser: true, - member, + isCurrentUser: false, + member: memberData, }); }); + it('does not show footer buttons', () => { + expect(findSaveButton().exists()).toBe(false); + expect(findCancelButton().exists()).toBe(false); + }); + + it('emits close event when drawer is closed', () => { + findDrawer().vm.$emit('close'); + + expect(wrapper.emitted('close')).toHaveLength(1); + }); + describe('role name', () => { it('shows the header', () => { expect(wrapper.findByTestId('role-header').text()).toBe('Role'); }); it('shows the role name', () => { - expect(wrapper.findByTestId('role-value').text()).toContain('Owner'); + expect(findRoleText().text()).toContain('Owner'); }); }); @@ -71,12 +106,12 @@ describe('Role details drawer', () => { expect(wrapper.findByTestId('permissions-header').text()).toBe('Permissions'); }); - it('shows the View permissions link', () => { - const link = wrapper.findComponent(GlButton); + it('shows the View permissions button', () => { + const button = wrapper.findByTestId('view-permissions-button'); - expect(link.text()).toBe('View permissions'); - expect(link.attributes('href')).toBe('/help/user/permissions'); - expect(link.props()).toMatchObject({ + expect(button.text()).toBe('View permissions'); + expect(button.attributes('href')).toBe('/help/user/permissions'); + expect(button.props()).toMatchObject({ icon: 'external-link', variant: 'link', target: '_blank', @@ -85,66 +120,190 @@ describe('Role details drawer', () => { }); }); - describe('when the member has a base role', () => { - beforeEach(() => { + describe('role selector', () => { + it('shows role name when the member cannot be edited', () => { createWrapper({ member: memberData }); + + expect(findRoleText().text()).toBe('Owner'); + expect(findRoleSelector().exists()).toBe(false); + }); + + it('shows role selector when member can be edited', () => { + createWrapper({ member: updateableMember }); + + expect(findRoleText().exists()).toBe(false); + expect(findRoleSelector().props()).toMatchObject({ + roles: dropdownItems, + value: role1, + loading: false, + }); + }); + }); + + describe('when the user only has read access', () => { + it('shows the custom role name', () => { + const member = { + ...memberData, + accessLevel: { stringValue: 'Custom role', memberRoleId: 102 }, + }; + createWrapper({ member }); + + expect(findRoleText().text()).toBe('Custom role'); + }); + }); + + describe('when role is changed', () => { + beforeEach(() => { + createWrapper(); + findRoleSelector().vm.$emit('input', role2); + }); + + it('shows save button', () => { + expect(findSaveButton().text()).toBe('Update role'); + expect(findSaveButton().props()).toMatchObject({ + variant: 'confirm', + loading: false, + }); + }); + + it('shows cancel button', () => { + expect(findCancelButton().props('variant')).toBe('default'); + expect(findCancelButton().props()).toMatchObject({ + variant: 'default', + loading: false, + }); + }); + + it('shows the new role in the role selector', () => { + expect(findRoleSelector().props('value')).toBe(role2); }); - it('does not show the custom role badge', () => { - expect(findCustomRoleBadge().exists()).toBe(false); + it('does not call update role API', () => { + expect(axiosMock.history.put).toHaveLength(0); }); - it('does not show the role description', () => { - expect(findDescriptionHeader().exists()).toBe(false); - expect(findDescriptionValue().exists()).toBe(false); + it('does not emit any events', () => { + expect(Object.keys(wrapper.emitted())).toHaveLength(0); }); - it('does not show the base role', () => { - expect(findBaseRole().exists()).toBe(false); + it('resets back to initial role when cancel button is clicked', async () => { + findCancelButton().vm.$emit('click'); + await nextTick(); + + expect(findRoleSelector().props('value')).toEqual(role1); + }); + }); + + describe('when update role button is clicked', () => { + beforeEach(() => { + axiosMock.onPut('user/path/238').replyOnce(200); + createWrapperChangeRoleAndClickSave(); + + return nextTick(); + }); + + it('calls update role API with expected data', () => { + const expectedData = JSON.stringify({ access_level: 30, member_role_id: null }); + + expect(axiosMock.history.put[0].data).toBe(expectedData); + }); + + it('disables footer buttons', () => { + expect(findSaveButton().props('loading')).toBe(true); + expect(findCancelButton().props('disabled')).toBe(true); + }); + + it('disables role dropdown', () => { + expect(findRoleSelector().props('loading')).toBe(true); + }); + + it('emits busy event as true', () => { + const busyEvents = wrapper.emitted('busy'); + + expect(busyEvents).toHaveLength(1); + expect(busyEvents[0][0]).toBe(true); + }); + + it('does not close the drawer when it is trying to close', () => { + findDrawer().vm.$emit('close'); + + expect(wrapper.emitted('close')).toBeUndefined(); + }); + }); + + describe('when update role API call is finished', () => { + beforeEach(() => { + axiosMock.onPut('user/path/238').replyOnce(200); + return createWrapperChangeRoleAndClickSave(); + }); + + it('hides footer buttons', () => { + expect(findSaveButton().exists()).toBe(false); + expect(findCancelButton().exists()).toBe(false); }); - it('does not show any permissions', () => { - expect(findPermissions()).toHaveLength(0); + it('enables role selector', () => { + expect(findRoleSelector().props('loading')).toBe(false); + }); + + it('emits busy event with false', () => { + const busyEvents = wrapper.emitted('busy'); + + expect(busyEvents).toHaveLength(2); + expect(busyEvents[1][0]).toBe(false); + }); + + it('shows toast', () => { + expect(toastShowMock).toHaveBeenCalledTimes(1); + expect(toastShowMock).toHaveBeenCalledWith('Role was successfully updated.'); }); }); - describe('when the member has a custom role', () => { + describe('when role admin approval is enabled and role is updated', () => { beforeEach(() => { - createWrapper({ member: memberWithCustomRole }); + axiosMock.onPut('user/path/238').replyOnce(200, { enqueued: true }); + return createWrapperChangeRoleAndClickSave(); }); - it('shows the custom role badge', () => { - expect(findCustomRoleBadge().props('size')).toBe('sm'); - expect(findCustomRoleBadge().text()).toBe('Custom role'); + it('resets role back to initial role', () => { + expect(findRoleSelector().props('value')).toEqual(role1); }); - it('shows the role description', () => { - expect(findDescriptionHeader().text()).toBe('Description'); - expect(findDescriptionValue().text()).toBe('Custom role description'); + it('shows toast', () => { + expect(toastShowMock).toHaveBeenCalledTimes(1); + expect(toastShowMock).toHaveBeenCalledWith( + 'Role change request was sent to the administrator.', + ); }); + }); - it('shows the base role', () => { - expect(findBaseRole().text()).toMatchInterpolatedText('Base role: Owner'); + describe('when update role API fails', () => { + beforeEach(() => { + axiosMock.onPut('user/path/238').replyOnce(500); + return createWrapperChangeRoleAndClickSave(); }); - it('shows the expected number of permissions', () => { - expect(findPermissions()).toHaveLength(2); + it('enables save and cancel buttons', () => { + expect(findSaveButton().props('loading')).toBe(false); + expect(findCancelButton().props('disabled')).toBe(false); }); - describe.each(permissions)(`for permission '$name'`, (permission) => { - const index = permissions.indexOf(permission); + it('enables role dropdown', () => { + expect(findRoleSelector().props('loading')).toBe(false); + }); - it('shows the check icon', () => { - expect(findPermissionAt(index).findComponent(GlIcon).props('name')).toBe('check'); - }); + it('emits busy event with false', () => { + const busyEvents = wrapper.emitted('busy'); - it('shows the permission name', () => { - expect(findPermissionNameAt(index).text()).toBe(`Permission ${index}`); - }); + expect(busyEvents).toHaveLength(2); + expect(busyEvents[1][0]).toBe(false); + }); - it('shows the permission description', () => { - expect(findPermissionDescriptionAt(index).text()).toBe(`Permission description ${index}`); - }); + it('shows error message', () => { + const alert = wrapper.findComponent(GlAlert); + + expect(alert.text()).toBe('Could not update role.'); + expect(alert.props('variant')).toBe('danger'); }); }); }); diff --git a/spec/frontend/members/components/table/role_selector_spec.js b/spec/frontend/members/components/table/role_selector_spec.js new file mode 100644 index 0000000000000..37bd866d21e72 --- /dev/null +++ b/spec/frontend/members/components/table/role_selector_spec.js @@ -0,0 +1,78 @@ +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { roleDropdownItems } from '~/members/utils'; +import RoleSelector from '~/members/components/table/role_selector.vue'; +import { member } from '../../mock_data'; + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + visitUrl: jest.fn(), +})); + +describe('Role selector', () => { + const dropdownItems = roleDropdownItems(member); + let wrapper; + + const createWrapper = ({ + roles = dropdownItems, + value = dropdownItems.flatten[0], + loading, + } = {}) => { + wrapper = mountExtended(RoleSelector, { + propsData: { roles, value, loading }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); + const getDropdownItem = (id) => wrapper.findByTestId(`listbox-item-${id}`); + const findRoleName = (id) => getDropdownItem(id).find('[data-testid="role-name"]'); + + describe('dropdown component', () => { + it('shows the dropdown with the expected props', () => { + createWrapper(); + + expect(findDropdown().props()).toMatchObject({ + headerText: 'Change role', + items: dropdownItems.formatted, + selected: dropdownItems.flatten[0].value, + loading: false, + block: true, + }); + }); + + it.each([true, false])('passes the loading state %s to the dropdown', (loading) => { + createWrapper({ loading }); + + expect(findDropdown().props('loading')).toBe(loading); + }); + + it('passes the selected item to the dropdown', () => { + createWrapper({ value: dropdownItems.flatten[5] }); + + expect(findDropdown().props('selected')).toBe(dropdownItems.flatten[5].value); + }); + + it('emits selected role when role is changed', () => { + createWrapper(); + findDropdown().vm.$emit('select', dropdownItems.flatten[5].value); + + expect(wrapper.emitted('input')[0][0]).toBe(dropdownItems.flatten[5]); + }); + + it('does not show manage role link', () => { + createWrapper(); + + expect(findDropdown().props('resetButtonLabel')).toBe(''); + }); + }); + + describe('dropdown items', () => { + beforeEach(() => { + createWrapper(); + }); + + it.each(dropdownItems.flatten)('shows the role name for $text', ({ value, text }) => { + expect(findRoleName(value).text()).toBe(text); + }); + }); +}); diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js index b40a2caf3c01d..73a0e3f9517e1 100644 --- a/spec/frontend/members/mock_data.js +++ b/spec/frontend/members/mock_data.js @@ -54,27 +54,6 @@ export const member = { customRoles: [], }; -export const memberWithCustomRole = { - ...member, - ...{ - accessLevel: { - ...member.accessLevel, - memberRoleId: 1, - description: 'Custom role description', - }, - customRoles: [ - { - memberRoleId: 1, - baseAccessLevel: 50, - permissions: [ - { name: 'Permission 0', description: 'Permission description 0' }, - { name: 'Permission 1', description: 'Permission description 1' }, - ], - }, - ], - }, -}; - export const group = { accessLevel: { integerValue: 10, stringValue: 'Guest' }, sharedWithGroup: { @@ -136,6 +115,7 @@ export const members = [member]; export const directMember = { ...member, isDirectMember: true }; export const inheritedMember = { ...member, isDirectMember: false }; +export const updateableMember = { ...directMember, canUpdate: true }; export const member2faEnabled = { ...member, user: { ...member.user, twoFactorEnabled: true } }; diff --git a/spec/frontend/members/utils_spec.js b/spec/frontend/members/utils_spec.js index 2c252b36478c0..6dfb0ea71f1ee 100644 --- a/spec/frontend/members/utils_spec.js +++ b/spec/frontend/members/utils_spec.js @@ -347,7 +347,7 @@ describe('Members Utils', () => { expect(flatten).toEqual(formatted); expect(flatten[0]).toMatchObject({ text: 'Guest', - value: 'role-static-0', + value: 'role-static-10', accessLevel: 10, memberRoleId: null, }); -- GitLab