Commit d4085e64 authored by Peter Hegman's avatar Peter Hegman Committed by Nicolò Maria Mezzopera

Add remove member action button

Part of a larger initiative to convert the group members view from
HAML to Vue
parent 67b2f0cc
......@@ -11,7 +11,7 @@ export const initGroupMembersApp = (el, tableFields) => {
Vue.use(Vuex);
const { members, groupId } = el.dataset;
const { members, groupId, memberPath } = el.dataset;
const store = new Vuex.Store({
...membersModule({
......@@ -19,6 +19,7 @@ export const initGroupMembersApp = (el, tableFields) => {
sourceId: parseInt(groupId, 10),
currentUserId: gon.current_user_id || null,
tableFields,
memberPath,
}),
});
......
<script>
import ActionButtonGroup from './action_button_group.vue';
import RemoveMemberButton from './remove_member_button.vue';
import { s__, sprintf } from '~/locale';
export default {
name: 'AccessRequestActionButtons',
components: { ActionButtonGroup, RemoveMemberButton },
props: {
member: {
type: Object,
required: true,
},
permissions: {
type: Object,
required: true,
},
isCurrentUser: {
type: Boolean,
required: true,
},
},
computed: {
message() {
const { user, source } = this.member;
if (this.isCurrentUser) {
return sprintf(
s__('Members|Are you sure you want to withdraw your access request for "%{source}"'),
{ source: source.name },
);
}
return sprintf(
s__('Members|Are you sure you want to deny %{usersName}\'s request to join "%{source}"'),
{ usersName: user.name, source: source.name },
);
},
},
};
</script>
<template>
<action-button-group>
<!-- Approve button will go here -->
<div v-if="permissions.canRemove" class="gl-px-1">
<remove-member-button
:member-id="member.id"
:message="message"
:title="s__('Member|Deny access')"
:is-access-request="true"
icon="close"
/>
</div>
</action-button-group>
</template>
<script>
export default {
name: 'ActionButtonGroup',
};
</script>
<template>
<div class="gl-display-flex gl-flex-align-items-center gl-justify-content-end gl-mx-n1">
<slot></slot>
</div>
</template>
<script>
export default {
name: 'GroupActionButtons',
};
</script>
<template>
<span>
<!-- Temporarily empty -->
</span>
</template>
<script>
import ActionButtonGroup from './action_button_group.vue';
import RemoveMemberButton from './remove_member_button.vue';
import { s__, sprintf } from '~/locale';
export default {
name: 'InviteActionButtons',
components: { ActionButtonGroup, RemoveMemberButton },
props: {
member: {
type: Object,
required: true,
},
permissions: {
type: Object,
required: true,
},
},
computed: {
message() {
const { invite, source } = this.member;
return sprintf(
s__(
'Members|Are you sure you want to revoke the invitation for %{inviteEmail} to join "%{source}"',
),
{ inviteEmail: invite.email, source: source.name },
);
},
},
};
</script>
<template>
<action-button-group>
<!-- Resend button will go here -->
<div v-if="permissions.canRemove" class="gl-px-1">
<remove-member-button
:member-id="member.id"
:message="message"
:title="s__('Member|Revoke invite')"
/>
</div>
</action-button-group>
</template>
<script>
import { mapState } from 'vuex';
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
export default {
name: 'RemoveMemberButton',
components: { GlButton },
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
memberId: {
type: Number,
required: true,
},
message: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
icon: {
type: String,
required: false,
default: 'remove',
},
isAccessRequest: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapState(['memberPath']),
computedMemberPath() {
return this.memberPath.replace(':id', this.memberId);
},
},
};
</script>
<template>
<gl-button
v-gl-tooltip.hover
class="js-remove-member-button"
variant="danger"
:title="title"
:aria-label="title"
:icon="icon"
:data-member-path="computedMemberPath"
:data-is-access-request="isAccessRequest"
:data-message="message"
data-qa-selector="delete_member_button"
/>
</template>
<script>
import ActionButtonGroup from './action_button_group.vue';
import RemoveMemberButton from './remove_member_button.vue';
import { s__, sprintf } from '~/locale';
export default {
name: 'UserActionButtons',
components: { ActionButtonGroup, RemoveMemberButton },
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.name,
},
);
}
return sprintf(
s__('Members|Are you sure you want to remove this orphaned member from "%{source}"'),
{
source: source.name,
},
);
},
},
};
</script>
<template>
<action-button-group>
<div v-if="permissions.canRemove" class="gl-px-1">
<template v-if="isCurrentUser">
<!-- Leave button will go here -->
</template>
<remove-member-button
v-else
:member-id="member.id"
:message="message"
:title="s__('Member|Remove member')"
/>
</div>
</action-button-group>
</template>
<script>
import UserActionButtons from '../action_buttons/user_action_buttons.vue';
import GroupActionButtons from '../action_buttons/group_action_buttons.vue';
import InviteActionButtons from '../action_buttons/invite_action_buttons.vue';
import AccessRequestActionButtons from '../action_buttons/access_request_action_buttons.vue';
import { MEMBER_TYPES } from '../constants';
export default {
name: 'MemberActionButtons',
components: {
UserActionButtons,
GroupActionButtons,
InviteActionButtons,
AccessRequestActionButtons,
},
props: {
member: {
type: Object,
required: true,
},
memberType: {
type: String,
required: true,
},
permissions: {
type: Object,
required: true,
},
isCurrentUser: {
type: Boolean,
required: true,
},
},
computed: {
actionButtonComponent() {
const dictionary = {
[MEMBER_TYPES.user]: 'user-action-buttons',
[MEMBER_TYPES.group]: 'group-action-buttons',
[MEMBER_TYPES.invite]: 'invite-action-buttons',
[MEMBER_TYPES.accessRequest]: 'access-request-action-buttons',
};
return dictionary[this.memberType];
},
},
};
</script>
<template>
<component
:is="actionButtonComponent"
v-if="actionButtonComponent"
:member="member"
:permissions="permissions"
:is-current-user="isCurrentUser"
/>
</template>
......@@ -7,6 +7,7 @@ import MemberAvatar from './member_avatar.vue';
import MemberSource from './member_source.vue';
import CreatedAt from './created_at.vue';
import ExpiresAt from './expires_at.vue';
import MemberActionButtons from './member_action_buttons.vue';
import MembersTableCell from './members_table_cell.vue';
export default {
......@@ -18,6 +19,7 @@ export default {
ExpiresAt,
MembersTableCell,
MemberSource,
MemberActionButtons,
},
computed: {
...mapState(['members', 'tableFields']),
......@@ -75,6 +77,17 @@ export default {
<expires-at :date="expiresAt" />
</template>
<template #cell(actions)="{ item: member }">
<members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member">
<member-action-buttons
:member-type="memberType"
:is-current-user="isCurrentUser"
:permissions="permissions"
:member="member"
/>
</members-table-cell>
</template>
<template #head(actions)="{ label }">
<span data-testid="col-actions" class="gl-sr-only">{{ label }}</span>
</template>
......
......@@ -38,12 +38,18 @@ export default {
isCurrentUser() {
return this.member.user?.id === this.currentUserId;
},
canRemove() {
return this.isDirectMember && this.member.canRemove;
},
},
render() {
return this.$scopedSlots.default({
memberType: this.memberType,
isDirectMember: this.isDirectMember,
isCurrentUser: this.isCurrentUser,
permissions: {
canRemove: this.canRemove,
},
});
},
};
......
export default ({ members, sourceId, currentUserId, tableFields }) => ({
export default ({ members, sourceId, currentUserId, tableFields, memberPath }) => ({
members,
sourceId,
currentUserId,
tableFields,
memberPath,
});
......@@ -69,7 +69,7 @@
= label_tag :sort_by, _('Sort by'), class: form_item_label_css_class
= render 'shared/members/sort_dropdown'
- if vue_members_list_enabled
.js-group-members-list{ data: { members: members_data_json(@group, @members), **data_attributes } }
.js-group-members-list{ data: { members: members_data_json(@group, @members), member_path: group_group_member_path(id: ':id'), **data_attributes } }
- else
%ul.content-list.members-list{ data: { qa_selector: 'members_list' } }
= render partial: 'shared/members/member', collection: @members, as: :member
......@@ -95,7 +95,7 @@
= form_tag group_group_members_path(@group), method: :get, class: 'user-search-form', data: { testid: 'user-search-form' } do
= render 'shared/members/search_field', name: 'search_invited'
- if vue_members_list_enabled
.js-group-invited-members-list{ data: { members: members_data_json(@group, @invited_members), **data_attributes } }
.js-group-invited-members-list{ data: { members: members_data_json(@group, @invited_members), member_path: group_group_member_path(id: ':id'), **data_attributes } }
- else
%ul.content-list.members-list
= render partial: 'shared/members/member', collection: @invited_members, as: :member
......@@ -107,7 +107,7 @@
= render 'groups/group_members/tab_pane/title' do
= html_escape(_('Users requesting access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
- if vue_members_list_enabled
.js-group-access-requests-list{ data: { members: members_data_json(@group, @requesters), **data_attributes } }
.js-group-access-requests-list{ data: { members: members_data_json(@group, @requesters), member_path: group_group_member_path(id: ':id'), **data_attributes } }
- else
%ul.content-list.members-list
= render partial: 'shared/members/member', collection: @requesters, as: :member
......@@ -15843,6 +15843,21 @@ msgstr ""
msgid "Members|%{time} by %{user}"
msgstr ""
msgid "Members|Are you sure you want to deny %{usersName}'s request to join \"%{source}\""
msgstr ""
msgid "Members|Are you sure you want to remove %{usersName} from \"%{source}\""
msgstr ""
msgid "Members|Are you sure you want to remove this orphaned member from \"%{source}\""
msgstr ""
msgid "Members|Are you sure you want to revoke the invitation for %{inviteEmail} to join \"%{source}\""
msgstr ""
msgid "Members|Are you sure you want to withdraw your access request for \"%{source}\""
msgstr ""
msgid "Members|Expired"
msgstr ""
......@@ -15852,6 +15867,15 @@ msgstr ""
msgid "Members|in %{time}"
msgstr ""
msgid "Member|Deny access"
msgstr ""
msgid "Member|Remove member"
msgstr ""
msgid "Member|Revoke invite"
msgstr ""
msgid "Memory Usage"
msgstr ""
......
......@@ -17,6 +17,7 @@ describe('initGroupMembersApp', () => {
el = document.createElement('div');
el.setAttribute('data-members', membersJsonString);
el.setAttribute('data-group-id', '234');
el.setAttribute('data-member-path', '/groups/foo-bar/-/group_members/:id');
window.gon = { current_user_id: 123 };
......@@ -69,4 +70,10 @@ describe('initGroupMembersApp', () => {
expect(vm.$store.state.tableFields).toEqual(['account']);
});
it('sets `memberPath` in Vuex store', () => {
setup();
expect(vm.$store.state.memberPath).toBe('/groups/foo-bar/-/group_members/:id');
});
});
import { shallowMount } from '@vue/test-utils';
import AccessRequestActionButtons from '~/vue_shared/components/members/action_buttons/access_request_action_buttons.vue';
import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue';
import { accessRequest as member } from '../mock_data';
describe('AccessRequestActionButtons', () => {
let wrapper;
const createComponent = (propsData = {}) => {
wrapper = shallowMount(AccessRequestActionButtons, {
propsData: {
member,
isCurrentUser: true,
...propsData,
},
});
};
const findRemoveMemberButton = () => wrapper.find(RemoveMemberButton);
afterEach(() => {
wrapper.destroy();
});
describe('when user has `canRemove` permissions', () => {
beforeEach(() => {
createComponent({
permissions: {
canRemove: true,
},
});
});
it('renders remove member button', () => {
expect(findRemoveMemberButton().exists()).toBe(true);
});
it('sets props correctly', () => {
expect(findRemoveMemberButton().props()).toMatchObject({
memberId: member.id,
title: 'Deny access',
isAccessRequest: true,
icon: 'close',
});
});
describe('when member is the current user', () => {
it('sets `message` prop correctly', () => {
expect(findRemoveMemberButton().props('message')).toBe(
`Are you sure you want to withdraw your access request for "${member.source.name}"`,
);
});
});
describe('when member is not the current user', () => {
it('sets `message` prop correctly', () => {
createComponent({
isCurrentUser: false,
permissions: {
canRemove: true,
},
});
expect(findRemoveMemberButton().props('message')).toBe(
`Are you sure you want to deny ${member.user.name}'s request to join "${member.source.name}"`,
);
});
});
});
describe('when user does not have `canRemove` permissions', () => {
it('does not render remove member button', () => {
createComponent({
permissions: {
canRemove: false,
},
});
expect(findRemoveMemberButton().exists()).toBe(false);
});
});
});
import { shallowMount } from '@vue/test-utils';
import InviteActionButtons from '~/vue_shared/components/members/action_buttons/invite_action_buttons.vue';
import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue';
import { invite as member } from '../mock_data';
describe('InviteActionButtons', () => {
let wrapper;
const createComponent = (propsData = {}) => {
wrapper = shallowMount(InviteActionButtons, {
propsData: {
member,
...propsData,
},
});
};
const findRemoveMemberButton = () => wrapper.find(RemoveMemberButton);
afterEach(() => {
wrapper.destroy();
});
describe('when user has `canRemove` permissions', () => {
beforeEach(() => {
createComponent({
permissions: {
canRemove: true,
},
});
});
it('renders remove member button', () => {
expect(findRemoveMemberButton().exists()).toBe(true);
});
it('sets props correctly', () => {
expect(findRemoveMemberButton().props()).toEqual({
memberId: member.id,
message: `Are you sure you want to revoke the invitation for ${member.invite.email} to join "${member.source.name}"`,
title: 'Revoke invite',
isAccessRequest: false,
icon: 'remove',
});
});
});
describe('when user does not have `canRemove` permissions', () => {
it('does not render remove member button', () => {
createComponent({
permissions: {
canRemove: false,
},
});
expect(findRemoveMemberButton().exists()).toBe(false);
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('RemoveMemberButton', () => {
let wrapper;
const createStore = (state = {}) => {
return new Vuex.Store({
state: {
memberPath: '/groups/foo-bar/-/group_members/:id',
...state,
},
});
};
const createComponent = (propsData = {}, state) => {
wrapper = shallowMount(RemoveMemberButton, {
localVue,
store: createStore(state),
propsData: {
memberId: 1,
message: 'Are you sure you want to remove John Smith?',
title: 'Remove member',
isAccessRequest: true,
...propsData,
},
directives: {
GlTooltip: createMockDirective(),
},
});
};
afterEach(() => {
wrapper.destroy();
});
it('sets attributes on button', () => {
createComponent();
expect(wrapper.attributes()).toMatchObject({
'data-member-path': '/groups/foo-bar/-/group_members/1',
'data-message': 'Are you sure you want to remove John Smith?',
'data-is-access-request': 'true',
'aria-label': 'Remove member',
title: 'Remove member',
icon: 'remove',
});
});
it('displays `title` prop as a tooltip', () => {
createComponent();
expect(getBinding(wrapper.element, 'gl-tooltip')).not.toBeUndefined();
});
it('has CSS class used by `remove_member_modal.vue`', () => {
createComponent();
expect(wrapper.classes()).toContain('js-remove-member-button');
});
});
import { shallowMount } from '@vue/test-utils';
import UserActionButtons from '~/vue_shared/components/members/action_buttons/user_action_buttons.vue';
import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue';
import { member, orphanedMember } from '../mock_data';
describe('UserActionButtons', () => {
let wrapper;
const createComponent = (propsData = {}) => {
wrapper = shallowMount(UserActionButtons, {
propsData: {
member,
isCurrentUser: false,
...propsData,
},
});
};
const findRemoveMemberButton = () => wrapper.find(RemoveMemberButton);
afterEach(() => {
wrapper.destroy();
});
describe('when user has `canRemove` permissions', () => {
beforeEach(() => {
createComponent({
permissions: {
canRemove: true,
},
});
});
it('renders remove member button', () => {
expect(findRemoveMemberButton().exists()).toBe(true);
});
it('sets props correctly', () => {
expect(findRemoveMemberButton().props()).toEqual({
memberId: member.id,
message: `Are you sure you want to remove ${member.user.name} from "${member.source.name}"`,
title: 'Remove member',
isAccessRequest: false,
icon: 'remove',
});
});
describe('when member is orphaned', () => {
it('sets `message` prop correctly', () => {
createComponent({
member: orphanedMember,
permissions: {
canRemove: true,
},
});
expect(findRemoveMemberButton().props('message')).toBe(
`Are you sure you want to remove this orphaned member from "${orphanedMember.source.name}"`,
);
});
});
});
describe('when user does not have `canRemove` permissions', () => {
it('does not render remove member button', () => {
createComponent({
permissions: {
canRemove: false,
},
});
expect(findRemoveMemberButton().exists()).toBe(false);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { MEMBER_TYPES } from '~/vue_shared/components/members/constants';
import { member as memberMock, group, invite, accessRequest } from '../mock_data';
import MemberActionButtons from '~/vue_shared/components/members/table/member_action_buttons.vue';
import UserActionButtons from '~/vue_shared/components/members/action_buttons/user_action_buttons.vue';
import GroupActionButtons from '~/vue_shared/components/members/action_buttons/group_action_buttons.vue';
import InviteActionButtons from '~/vue_shared/components/members/action_buttons/invite_action_buttons.vue';
import AccessRequestActionButtons from '~/vue_shared/components/members/action_buttons/access_request_action_buttons.vue';
describe('MemberActionButtons', () => {
let wrapper;
const createComponent = (propsData = {}) => {
wrapper = shallowMount(MemberActionButtons, {
propsData: {
isCurrentUser: false,
permissions: {
canRemove: true,
},
...propsData,
},
});
};
afterEach(() => {
wrapper.destroy();
});
test.each`
memberType | member | expectedComponent | expectedComponentName
${MEMBER_TYPES.user} | ${memberMock} | ${UserActionButtons} | ${'UserActionButtons'}
${MEMBER_TYPES.group} | ${group} | ${GroupActionButtons} | ${'GroupActionButtons'}
${MEMBER_TYPES.invite} | ${invite} | ${InviteActionButtons} | ${'InviteActionButtons'}
${MEMBER_TYPES.accessRequest} | ${accessRequest} | ${AccessRequestActionButtons} | ${'AccessRequestActionButtons'}
`(
'renders $expectedComponentName when `memberType` is $memberType',
({ memberType, member, expectedComponent }) => {
createComponent({ memberType, member });
expect(wrapper.find(expectedComponent).exists()).toBe(true);
},
);
});
......@@ -19,6 +19,10 @@ describe('MemberList', () => {
type: Boolean,
required: true,
},
permissions: {
type: Object,
required: true,
},
},
render(createElement) {
return createElement('div', this.memberType);
......@@ -52,6 +56,7 @@ describe('MemberList', () => {
:member-type="props.memberType"
:is-direct-member="props.isDirectMember"
:is-current-user="props.isCurrentUser"
:permissions="props.permissions"
/>
`,
},
......@@ -60,6 +65,24 @@ describe('MemberList', () => {
const findWrappedComponent = () => wrapper.find(WrappedComponent);
const createComponentWithDirectMember = (member = {}) => {
createComponent({
member: {
...memberMock,
source: {
...memberMock.source,
id: 1,
},
...member,
},
});
};
const createComponentWithInheritedMember = (member = {}) => {
createComponent({
member: { ...memberMock, ...member },
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
......@@ -82,23 +105,13 @@ describe('MemberList', () => {
describe('isDirectMember', () => {
it('returns `true` when member source has same ID as `sourceId`', () => {
createComponent({
member: {
...memberMock,
source: {
...memberMock.source,
id: 1,
},
},
});
createComponentWithDirectMember();
expect(findWrappedComponent().props('isDirectMember')).toBe(true);
});
it('returns `false` when member is inherited', () => {
createComponent({
member: memberMock,
});
createComponentWithInheritedMember();
expect(findWrappedComponent().props('isDirectMember')).toBe(false);
});
......@@ -127,4 +140,34 @@ describe('MemberList', () => {
expect(findWrappedComponent().props('isCurrentUser')).toBe(false);
});
});
describe('permissions', () => {
describe('canRemove', () => {
describe('for a direct member', () => {
it('returns `true` when `canRemove` is `true`', () => {
createComponentWithDirectMember({
canRemove: true,
});
expect(findWrappedComponent().props('permissions').canRemove).toBe(true);
});
it('returns `false` when `canRemove` is `false`', () => {
createComponentWithDirectMember({
canRemove: false,
});
expect(findWrappedComponent().props('permissions').canRemove).toBe(false);
});
});
describe('for an inherited member', () => {
it('returns `false`', () => {
createComponentWithInheritedMember();
expect(findWrappedComponent().props('permissions').canRemove).toBe(false);
});
});
});
});
});
......@@ -9,6 +9,7 @@ import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vu
import MemberSource from '~/vue_shared/components/members/table/member_source.vue';
import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue';
import CreatedAt from '~/vue_shared/components/members/table/created_at.vue';
import MemberActionButtons from '~/vue_shared/components/members/table/member_action_buttons.vue';
import * as initUserPopovers from '~/user_popovers';
import { member as memberMock, invite, accessRequest } from '../mock_data';
......@@ -32,7 +33,13 @@ describe('MemberList', () => {
wrapper = mount(MembersTable, {
localVue,
store: createStore(state),
stubs: ['member-avatar', 'member-source', 'expires-at', 'created-at'],
stubs: [
'member-avatar',
'member-source',
'expires-at',
'created-at',
'member-action-buttons',
],
});
};
......@@ -77,12 +84,18 @@ describe('MemberList', () => {
});
it('renders "Actions" field for screen readers', () => {
createComponent({ tableFields: ['actions'] });
createComponent({ members: [memberMock], tableFields: ['actions'] });
const actionField = getByTestId('col-actions');
expect(actionField.exists()).toBe(true);
expect(actionField.classes('gl-sr-only')).toBe(true);
expect(
wrapper
.find(`[data-label="Actions"][role="cell"]`)
.find(MemberActionButtons)
.exists(),
).toBe(true);
});
});
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment