Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-12-29 15:08:35 +00:00
parent afbe8fe679
commit dbdfb3e36d
20 changed files with 414 additions and 213 deletions

View File

@ -1,83 +0,0 @@
<script>
import { s__, __, sprintf } from '~/locale';
import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
import ActionButtonGroup from './action_button_group.vue';
import LeaveButton from './leave_button.vue';
import RemoveMemberButton from './remove_member_button.vue';
export default {
name: 'UserActionButtons',
i18n: {
title: __('Remove member'),
},
components: {
ActionButtonGroup,
RemoveMemberButton,
LeaveButton,
LdapOverrideButton: () =>
import('ee_component/members/components/ldap/ldap_override_button.vue'),
},
props: {
member: {
type: Object,
required: true,
},
isCurrentUser: {
type: Boolean,
required: true,
},
permissions: {
type: Object,
required: true,
},
},
computed: {
message() {
const { user, source } = this.member;
if (user) {
return sprintf(
s__('Members|Are you sure you want to remove %{usersName} from "%{source}"?'),
{
usersName: user.name,
source: source.fullName,
},
false,
);
}
return sprintf(
s__('Members|Are you sure you want to remove this orphaned member from "%{source}"?'),
{
source: source.fullName,
},
);
},
userDeletionObstaclesUserData() {
return {
name: this.member.user?.name,
obstacles: parseUserDeletionObstacles(this.member.user),
};
},
},
};
</script>
<template>
<action-button-group>
<div v-if="permissions.canRemove" class="gl-px-1">
<leave-button v-if="isCurrentUser" :member="member" />
<remove-member-button
v-else
:member-id="member.id"
:member-type="member.type"
:user-deletion-obstacles="userDeletionObstaclesUserData"
:message="message"
:title="$options.i18n.title"
/>
</div>
<div v-else-if="permissions.canOverride && !member.isOverridden" class="gl-px-1">
<ldap-override-button :member="member" />
</div>
</action-button-group>
</template>

View File

@ -0,0 +1,14 @@
import { __, s__ } from '~/locale';
export const I18N = {
actions: __('More actions'),
editPermissions: s__('Members|Edit permissions'),
leaveGroup: __('Leave group'),
removeMember: __('Remove member'),
confirmNormalUserRemoval: s__(
'Members|Are you sure you want to remove %{userName} from "%{group}"?',
),
confirmOrphanedUserRemoval: s__(
'Members|Are you sure you want to remove this orphaned member from "%{group}"?',
),
};

View File

@ -1,20 +1,17 @@
<script>
import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import { GlDropdownItem, GlModalDirective } from '@gitlab/ui';
import { LEAVE_MODAL_ID } from '../../constants';
import LeaveModal from '../modals/leave_modal.vue';
export default {
name: 'LeaveButton',
title: __('Leave'),
name: 'LeaveGroupDropdownItem',
modalId: LEAVE_MODAL_ID,
components: {
GlButton,
GlDropdownItem,
LeaveModal,
},
directives: {
GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective,
},
props: {
member: {
@ -26,14 +23,10 @@ export default {
</script>
<template>
<div>
<gl-button
v-gl-tooltip.hover
v-gl-modal="$options.modalId"
:title="$options.title"
:aria-label="$options.title"
icon="leave"
/>
<gl-dropdown-item v-gl-modal="$options.modalId">
<span class="gl-text-red-500">
<slot></slot>
</span>
<leave-modal :member="member" />
</div>
</gl-dropdown-item>
</template>

View File

@ -0,0 +1,75 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
export default {
name: 'RemoveMemberDropdownItem',
components: { GlDropdownItem },
inject: ['namespace'],
props: {
memberId: {
type: Number,
required: true,
},
memberType: {
type: String,
required: false,
default: null,
},
modalMessage: {
type: String,
required: true,
},
isAccessRequest: {
type: Boolean,
required: false,
default: false,
},
isInvite: {
type: Boolean,
required: false,
default: false,
},
userDeletionObstacles: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
...mapState({
memberPath(state) {
return state[this.namespace].memberPath;
},
}),
modalData() {
return {
isAccessRequest: this.isAccessRequest,
isInvite: this.isInvite,
memberPath: this.memberPath.replace(':id', this.memberId),
memberType: this.memberType,
message: this.modalMessage,
userDeletionObstacles: this.userDeletionObstacles,
};
},
},
methods: {
...mapActions({
showRemoveMemberModal(dispatch, payload) {
return dispatch(`${this.namespace}/showRemoveMemberModal`, payload);
},
}),
},
};
</script>
<template>
<gl-dropdown-item
data-qa-selector="delete_member_dropdown_item"
@click="showRemoveMemberModal(modalData)"
>
<span class="gl-text-red-500">
<slot></slot>
</span>
</gl-dropdown-item>
</template>

View File

@ -0,0 +1,96 @@
<script>
import { GlDropdown, GlTooltipDirective } from '@gitlab/ui';
import { sprintf } from '~/locale';
import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
import { I18N } from './constants';
import LeaveGroupDropdownItem from './leave_group_dropdown_item.vue';
import RemoveMemberDropdownItem from './remove_member_dropdown_item.vue';
export default {
name: 'UserActionDropdown',
i18n: I18N,
components: {
GlDropdown,
LdapOverrideDropdownItem: () =>
import('ee_component/members/components/ldap/ldap_override_dropdown_item.vue'),
LeaveGroupDropdownItem,
RemoveMemberDropdownItem,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
member: {
type: Object,
required: true,
},
isCurrentUser: {
type: Boolean,
required: true,
},
permissions: {
type: Object,
required: true,
},
},
computed: {
modalMessage() {
const { user, source } = this.member;
if (user) {
return sprintf(
this.$options.i18n.confirmNormalUserRemoval,
{ userName: user.name, group: source.fullName },
false,
);
}
return sprintf(this.$options.i18n.confirmOrphanedUserRemoval, { group: source.fullName });
},
userDeletionObstaclesUserData() {
return {
name: this.member.user?.name,
obstacles: parseUserDeletionObstacles(this.member.user),
};
},
showDropdown() {
return this.permissions.canRemove || this.showLdapOverride;
},
showLdapOverride() {
return this.permissions.canOverride && !this.member.isOverridden;
},
},
};
</script>
<template>
<gl-dropdown
v-if="showDropdown"
v-gl-tooltip="$options.i18n.actions"
:text="$options.i18n.actions"
text-sr-only
icon="ellipsis_v"
category="tertiary"
no-caret
right
data-testid="user-action-dropdown"
data-qa-selector="user_action_dropdown"
>
<template v-if="permissions.canRemove">
<leave-group-dropdown-item v-if="isCurrentUser" :member="member">{{
$options.i18n.leaveGroup
}}</leave-group-dropdown-item>
<remove-member-dropdown-item
v-else
:member-id="member.id"
:member-type="member.type"
:user-deletion-obstacles="userDeletionObstaclesUserData"
:modal-message="modalMessage"
>{{ $options.i18n.removeMember }}</remove-member-dropdown-item
>
</template>
<ldap-override-dropdown-item v-else-if="showLdapOverride" :member="member">{{
$options.i18n.editPermissions
}}</ldap-override-dropdown-item>
</gl-dropdown>
</template>

View File

@ -3,12 +3,12 @@ import { MEMBER_TYPES, EE_ACTION_BUTTONS } from 'ee_else_ce/members/constants';
import AccessRequestActionButtons from '../action_buttons/access_request_action_buttons.vue';
import GroupActionButtons from '../action_buttons/group_action_buttons.vue';
import InviteActionButtons from '../action_buttons/invite_action_buttons.vue';
import UserActionButtons from '../action_buttons/user_action_buttons.vue';
import UserActionDropdown from '../action_dropdowns/user_action_dropdown.vue';
export default {
name: 'MemberActionButtons',
components: {
UserActionButtons,
UserActionDropdown,
GroupActionButtons,
InviteActionButtons,
AccessRequestActionButtons,
@ -36,7 +36,7 @@ export default {
computed: {
actionButtonComponent() {
const dictionary = {
[MEMBER_TYPES.user]: 'user-action-buttons',
[MEMBER_TYPES.user]: 'user-action-dropdown',
[MEMBER_TYPES.group]: 'group-action-buttons',
[MEMBER_TYPES.invite]: 'invite-action-buttons',
[MEMBER_TYPES.accessRequest]: 'access-request-action-buttons',

View File

@ -5,6 +5,7 @@ module ContainerRegistry
include Gitlab::Utils::StrongMemoize
attr_accessor :uri
attr_reader :options, :base_uri
REGISTRY_VERSION_HEADER = 'gitlab-container-registry-version'
REGISTRY_FEATURES_HEADER = 'gitlab-container-registry-features'

View File

@ -25824,10 +25824,10 @@ msgstr ""
msgid "Members|Are you sure you want to remove \"%{groupName}\"?"
msgstr ""
msgid "Members|Are you sure you want to remove %{usersName} from \"%{source}\"?"
msgid "Members|Are you sure you want to remove %{userName} from \"%{group}\"?"
msgstr ""
msgid "Members|Are you sure you want to remove this orphaned member from \"%{source}\"?"
msgid "Members|Are you sure you want to remove this orphaned member from \"%{group}\"?"
msgstr ""
msgid "Members|Are you sure you want to revoke the invitation for %{inviteEmail} to join \"%{source}\""

View File

@ -22,8 +22,12 @@ module QA
element :access_level_link
end
view 'app/assets/javascripts/members/components/action_buttons/remove_member_button.vue' do
element :delete_member_button
view 'app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue' do
element :user_action_dropdown
end
view 'app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue' do
element :delete_member_dropdown_item
end
view 'app/assets/javascripts/members/components/members_tabs.vue' do
@ -41,7 +45,8 @@ module QA
def remove_member(username)
within_element(:member_row, text: username) do
click_element :delete_member_button
click_element :user_action_dropdown
click_element :delete_member_dropdown_item
end
within_element(:remove_member_modal) do

View File

@ -269,9 +269,12 @@ RSpec.describe 'Admin Groups', feature_category: :subgroups do
expect(page).to have_content('Developer')
end
find_member_row(current_user).click_button(title: 'Leave')
show_actions_for_username(current_user)
click_button _('Leave group')
accept_gl_confirm(button_text: 'Leave')
within_modal do
click_button _('Leave')
end
wait_for_all_requests

View File

@ -151,12 +151,11 @@ RSpec.describe "Admin::Projects", feature_category: :projects do
expect(find_member_row(current_user)).to have_content('Developer')
page.within find_member_row(current_user) do
click_button 'Leave'
end
show_actions_for_username(current_user)
click_button _('Leave group')
within_modal do
click_button('Leave')
click_button _('Leave')
end
expect(page).to have_current_path(dashboard_projects_path, ignore_query: true, url: false)

View File

@ -50,12 +50,13 @@ RSpec.describe 'Groups > Members > Manage members', feature_category: :subgroups
# Open modal
page.within(second_row) do
click_button 'Remove member'
show_actions
click_button _('Remove member')
end
within_modal do
expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests'
click_button('Remove member')
click_button _('Remove member')
end
wait_for_requests

View File

@ -139,17 +139,15 @@ RSpec.describe 'Projects > Members > Manage members', :js, feature_category: :on
it 'can only remove non-Owner members' do
page.within find_member_row(project_owner) do
expect(page).not_to have_button('Remove member')
expect(page).not_to have_selector user_action_dropdown
end
# Open modal
page.within find_member_row(project_developer) do
click_button 'Remove member'
end
show_actions_for_username(project_developer)
click_button _('Remove member')
within_modal do
expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests'
click_button('Remove member')
click_button _('Remove member')
end
wait_for_requests
@ -163,18 +161,12 @@ RSpec.describe 'Projects > Members > Manage members', :js, feature_category: :on
let(:current_user) { group_owner }
it 'can remove any direct member' do
page.within find_member_row(project_owner) do
expect(page).to have_button('Remove member')
end
# Open modal
page.within find_member_row(project_owner) do
click_button 'Remove member'
end
show_actions_for_username(project_owner)
click_button _('Remove member')
within_modal do
expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests'
click_button('Remove member')
click_button _('Remove member')
end
wait_for_requests

View File

@ -22,13 +22,12 @@ RSpec.describe 'Projects > Settings > User manages project members', feature_cat
it 'cancels a team member', :js do
visit(project_project_members_path(project))
page.within find_member_row(user_dmitriy) do
click_button 'Remove member'
end
show_actions_for_username(user_dmitriy)
click_button _('Remove member')
within_modal do
expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests'
click_button('Remove member')
click_button _('Remove member')
end
visit(project_project_members_path(project))

View File

@ -1,59 +0,0 @@
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import LeaveButton from '~/members/components/action_buttons/leave_button.vue';
import LeaveModal from '~/members/components/modals/leave_modal.vue';
import { LEAVE_MODAL_ID } from '~/members/constants';
import { member } from '../../mock_data';
describe('LeaveButton', () => {
let wrapper;
const createComponent = (propsData = {}) => {
wrapper = shallowMount(LeaveButton, {
propsData: {
member,
...propsData,
},
directives: {
GlTooltip: createMockDirective(),
GlModal: createMockDirective(),
},
});
};
const findButton = () => wrapper.findComponent(GlButton);
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('displays a tooltip', () => {
const button = findButton();
expect(getBinding(button.element, 'gl-tooltip')).not.toBeUndefined();
expect(button.attributes('title')).toBe('Leave');
});
it('sets `aria-label` attribute', () => {
expect(findButton().attributes('aria-label')).toBe('Leave');
});
it('renders leave modal', () => {
const leaveModal = wrapper.findComponent(LeaveModal);
expect(leaveModal.exists()).toBe(true);
expect(leaveModal.props('member')).toEqual(member);
});
it('triggers leave modal', () => {
const binding = getBinding(findButton().element, 'gl-modal');
expect(binding).not.toBeUndefined();
expect(binding.value).toBe(LEAVE_MODAL_ID);
});
});

View File

@ -0,0 +1,53 @@
import { GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import LeaveGroupDropdownItem from '~/members/components/action_dropdowns/leave_group_dropdown_item.vue';
import LeaveModal from '~/members/components/modals/leave_modal.vue';
import { LEAVE_MODAL_ID } from '~/members/constants';
import { member } from '../../mock_data';
describe('LeaveGroupDropdownItem', () => {
let wrapper;
const text = 'dummy';
const createComponent = (propsData = {}) => {
wrapper = shallowMount(LeaveGroupDropdownItem, {
propsData: {
member,
...propsData,
},
directives: {
GlModal: createMockDirective(),
},
slots: {
default: text,
},
});
};
const findDropdownItem = () => wrapper.findComponent(GlDropdownItem);
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders a slot with red text', () => {
expect(findDropdownItem().html()).toContain(`<span class="gl-text-red-500">${text}</span>`);
});
it('contains LeaveModal component', () => {
const leaveModal = wrapper.findComponent(LeaveModal);
expect(leaveModal.props('member')).toEqual(member);
});
it('binds to the LeaveModal component', () => {
const binding = getBinding(findDropdownItem().element, 'gl-modal');
expect(binding.value).toBe(LEAVE_MODAL_ID);
});
});

View File

@ -0,0 +1,74 @@
import { GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import { modalData } from 'jest/members/mock_data';
import RemoveMemberDropdownItem from '~/members/components/action_dropdowns/remove_member_dropdown_item.vue';
import { MEMBER_TYPES } from '~/members/constants';
Vue.use(Vuex);
describe('RemoveMemberDropdownItem', () => {
let wrapper;
const text = 'dummy';
const actions = {
showRemoveMemberModal: jest.fn(),
};
const createStore = (state = {}) => {
return new Vuex.Store({
modules: {
[MEMBER_TYPES.user]: {
namespaced: true,
state: {
memberPath: '/groups/foo-bar/-/group_members/:id',
...state,
},
actions,
},
},
});
};
const createComponent = (propsData = {}, state) => {
wrapper = shallowMount(RemoveMemberDropdownItem, {
store: createStore(state),
provide: {
namespace: MEMBER_TYPES.user,
},
propsData: {
memberId: 1,
memberType: 'GroupMember',
modalMessage: 'Are you sure you want to remove John Smith?',
isAccessRequest: true,
isInvite: true,
userDeletionObstacles: { name: 'user', obstacles: [] },
...propsData,
},
slots: {
default: text,
},
});
};
const findDropdownItem = () => wrapper.findComponent(GlDropdownItem);
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders a slot with red text', () => {
expect(findDropdownItem().html()).toContain(`<span class="gl-text-red-500">${text}</span>`);
});
it('calls Vuex action to show `remove member` modal when clicked', () => {
findDropdownItem().vm.$emit('click');
expect(actions.showRemoveMemberModal).toHaveBeenCalledWith(expect.any(Object), modalData);
});
});

View File

@ -1,25 +1,31 @@
import { shallowMount } from '@vue/test-utils';
import LeaveButton from '~/members/components/action_buttons/leave_button.vue';
import RemoveMemberButton from '~/members/components/action_buttons/remove_member_button.vue';
import UserActionButtons from '~/members/components/action_buttons/user_action_buttons.vue';
import { sprintf } from '~/locale';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import LeaveGroupDropdownItem from '~/members/components/action_dropdowns/leave_group_dropdown_item.vue';
import RemoveMemberDropdownItem from '~/members/components/action_dropdowns/remove_member_dropdown_item.vue';
import UserActionDropdown from '~/members/components/action_dropdowns/user_action_dropdown.vue';
import { I18N } from '~/members/components/action_dropdowns/constants';
import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
import { member, orphanedMember } from '../../mock_data';
describe('UserActionButtons', () => {
describe('UserActionDropdown', () => {
let wrapper;
const createComponent = (propsData = {}) => {
wrapper = shallowMount(UserActionButtons, {
wrapper = shallowMount(UserActionDropdown, {
propsData: {
member,
isCurrentUser: false,
isInvitedUser: false,
...propsData,
},
directives: {
GlTooltip: createMockDirective(),
},
});
};
const findRemoveMemberButton = () => wrapper.findComponent(RemoveMemberButton);
const findRemoveMemberDropdownItem = () => wrapper.findComponent(RemoveMemberDropdownItem);
afterEach(() => {
wrapper.destroy();
@ -34,16 +40,30 @@ describe('UserActionButtons', () => {
});
});
it('renders remove member button', () => {
expect(findRemoveMemberButton().exists()).toBe(true);
it('renders remove member dropdown with correct text', () => {
const removeMemberDropdownItem = findRemoveMemberDropdownItem();
expect(removeMemberDropdownItem.exists()).toBe(true);
expect(removeMemberDropdownItem.html()).toContain(I18N.removeMember);
});
it('displays a tooltip', () => {
const tooltip = getBinding(wrapper.element, 'gl-tooltip');
expect(tooltip).not.toBeUndefined();
expect(tooltip.value).toBe(I18N.actions);
});
it('sets props correctly', () => {
expect(findRemoveMemberButton().props()).toEqual({
expect(findRemoveMemberDropdownItem().props()).toEqual({
memberId: member.id,
memberType: 'GroupMember',
message: `Are you sure you want to remove ${member.user.name} from "${member.source.fullName}"?`,
title: UserActionButtons.i18n.title,
modalMessage: sprintf(
I18N.confirmNormalUserRemoval,
{
userName: member.user.name,
group: member.source.fullName,
},
false,
),
isAccessRequest: false,
isInvite: false,
userDeletionObstacles: {
@ -62,14 +82,14 @@ describe('UserActionButtons', () => {
},
});
expect(findRemoveMemberButton().props('message')).toBe(
`Are you sure you want to remove this orphaned member from "${orphanedMember.source.fullName}"?`,
expect(findRemoveMemberDropdownItem().props('modalMessage')).toBe(
sprintf(I18N.confirmOrphanedUserRemoval, { group: orphanedMember.source.fullName }),
);
});
});
describe('when member is the current user', () => {
it('renders leave button', () => {
it('renders leave dropdown with correct text', () => {
createComponent({
isCurrentUser: true,
permissions: {
@ -77,20 +97,22 @@ describe('UserActionButtons', () => {
},
});
expect(wrapper.findComponent(LeaveButton).exists()).toBe(true);
const leaveGroupDropdownItem = wrapper.findComponent(LeaveGroupDropdownItem);
expect(leaveGroupDropdownItem.exists()).toBe(true);
expect(leaveGroupDropdownItem.html()).toContain(I18N.leaveGroup);
});
});
});
describe('when user does not have `canRemove` permissions', () => {
it('does not render remove member button', () => {
it('does not render remove member dropdown', () => {
createComponent({
permissions: {
canRemove: false,
},
});
expect(findRemoveMemberButton().exists()).toBe(false);
expect(findRemoveMemberDropdownItem().exists()).toBe(false);
});
});
@ -108,7 +130,7 @@ describe('UserActionButtons', () => {
});
it('sets member type correctly', () => {
expect(findRemoveMemberButton().props().memberType).toBe('GroupMember');
expect(findRemoveMemberDropdownItem().props().memberType).toBe('GroupMember');
});
});
@ -126,7 +148,7 @@ describe('UserActionButtons', () => {
});
it('sets member type correctly', () => {
expect(findRemoveMemberButton().props().memberType).toBe('ProjectMember');
expect(findRemoveMemberDropdownItem().props().memberType).toBe('ProjectMember');
});
});
});

View File

@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import AccessRequestActionButtons from '~/members/components/action_buttons/access_request_action_buttons.vue';
import GroupActionButtons from '~/members/components/action_buttons/group_action_buttons.vue';
import InviteActionButtons from '~/members/components/action_buttons/invite_action_buttons.vue';
import UserActionButtons from '~/members/components/action_buttons/user_action_buttons.vue';
import UserActionDropdown from '~/members/components/action_dropdowns/user_action_dropdown.vue';
import MemberActionButtons from '~/members/components/table/member_action_buttons.vue';
import { MEMBER_TYPES } from '~/members/constants';
import { member as memberMock, group, invite, accessRequest } from '../../mock_data';
@ -29,7 +29,7 @@ describe('MemberActionButtons', () => {
it.each`
memberType | member | expectedComponent | expectedComponentName
${MEMBER_TYPES.user} | ${memberMock} | ${UserActionButtons} | ${'UserActionButtons'}
${MEMBER_TYPES.user} | ${memberMock} | ${UserActionDropdown} | ${'UserActionDropdown'}
${MEMBER_TYPES.group} | ${group} | ${GroupActionButtons} | ${'GroupActionButtons'}
${MEMBER_TYPES.invite} | ${invite} | ${InviteActionButtons} | ${'InviteActionButtons'}
${MEMBER_TYPES.accessRequest} | ${accessRequest} | ${AccessRequestActionButtons} | ${'AccessRequestActionButtons'}

View File

@ -56,6 +56,22 @@ module Spec
click_button 'Search'
end
end
def user_action_dropdown
'[data-testid="user-action-dropdown"]'
end
def show_actions
within user_action_dropdown do
find('button').click
end
end
def show_actions_for_username(user)
within find_username_row(user) do
show_actions
end
end
end
end
end