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