Commit 01cf7d45 authored by Peter Hegman's avatar Peter Hegman Committed by Olena Horal-Koretska

Add remove group link button to members table

Part of a larger initiative to convert the group members view from
HAML to Vue
parent b963e72d
<script> <script>
import ActionButtonGroup from './action_button_group.vue';
import RemoveGroupLinkButton from './remove_group_link_button.vue';
export default { export default {
name: 'GroupActionButtons', name: 'GroupActionButtons',
components: { ActionButtonGroup, RemoveGroupLinkButton },
props: {
member: {
type: Object,
required: true,
},
permissions: {
type: Object,
required: true,
},
},
}; };
</script> </script>
<template> <template>
<span> <action-button-group>
<!-- Temporarily empty --> <div v-if="permissions.canRemove" class="gl-px-1">
</span> <remove-group-link-button :group-link="member" />
</div>
</action-button-group>
</template> </template>
<script>
import { mapActions } from 'vuex';
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
name: 'RemoveGroupLinkButton',
i18n: {
buttonTitle: s__('Members|Remove group'),
},
components: { GlButton },
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
groupLink: {
type: Object,
required: true,
},
},
methods: {
...mapActions(['showRemoveGroupLinkModal']),
},
};
</script>
<template>
<gl-button
v-gl-tooltip.hover
variant="danger"
:title="$options.i18n.buttonTitle"
:aria-label="$options.i18n.buttonTitle"
icon="remove"
@click="showRemoveGroupLinkModal(groupLink)"
/>
</template>
...@@ -66,3 +66,5 @@ export const MEMBER_TYPES = { ...@@ -66,3 +66,5 @@ export const MEMBER_TYPES = {
export const DAYS_TO_EXPIRE_SOON = 7; export const DAYS_TO_EXPIRE_SOON = 7;
export const LEAVE_MODAL_ID = 'member-leave-modal'; export const LEAVE_MODAL_ID = 'member-leave-modal';
export const REMOVE_GROUP_LINK_MODAL_ID = 'remove-group-link-modal-id';
<script>
import { mapState, mapActions } from 'vuex';
import { GlModal, GlSprintf, GlForm } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
import { __, s__, sprintf } from '~/locale';
import { REMOVE_GROUP_LINK_MODAL_ID } from '../constants';
export default {
name: 'RemoveGroupLinkModal',
actionCancel: {
text: __('Cancel'),
},
actionPrimary: {
text: s__('Members|Remove group'),
attributes: {
variant: 'danger',
},
},
csrf,
i18n: {
modalBody: s__('Members|Are you sure you want to remove "%{groupName}"?'),
},
modalId: REMOVE_GROUP_LINK_MODAL_ID,
components: { GlModal, GlSprintf, GlForm },
computed: {
...mapState(['memberPath', 'groupLinkToRemove', 'removeGroupLinkModalVisible']),
groupLinkPath() {
return this.memberPath.replace(/:id$/, this.groupLinkToRemove?.id);
},
groupName() {
return this.groupLinkToRemove?.sharedWithGroup.fullName;
},
modalTitle() {
return sprintf(s__('Members|Remove "%{groupName}"'), { groupName: this.groupName });
},
},
methods: {
...mapActions(['hideRemoveGroupLinkModal']),
handlePrimary() {
this.$refs.form.$el.submit();
},
},
};
</script>
<template>
<gl-modal
v-bind="$attrs"
:modal-id="$options.modalId"
:visible="removeGroupLinkModalVisible"
:title="modalTitle"
:action-primary="$options.actionPrimary"
:action-cancel="$options.actionCancel"
size="sm"
@primary="handlePrimary"
@hide="hideRemoveGroupLinkModal"
>
<gl-form ref="form" :action="groupLinkPath" method="post">
<p>
<gl-sprintf :message="$options.i18n.modalBody">
<template #groupName>{{ groupName }}</template>
</gl-sprintf>
</p>
<input type="hidden" name="_method" value="delete" />
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
</gl-form>
</gl-modal>
</template>
...@@ -10,6 +10,7 @@ import ExpiresAt from './expires_at.vue'; ...@@ -10,6 +10,7 @@ import ExpiresAt from './expires_at.vue';
import MemberActionButtons from './member_action_buttons.vue'; import MemberActionButtons from './member_action_buttons.vue';
import MembersTableCell from './members_table_cell.vue'; import MembersTableCell from './members_table_cell.vue';
import RoleDropdown from './role_dropdown.vue'; import RoleDropdown from './role_dropdown.vue';
import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue';
export default { export default {
name: 'MembersTable', name: 'MembersTable',
...@@ -23,6 +24,7 @@ export default { ...@@ -23,6 +24,7 @@ export default {
MemberSource, MemberSource,
MemberActionButtons, MemberActionButtons,
RoleDropdown, RoleDropdown,
RemoveGroupLinkModal,
}, },
computed: { computed: {
...mapState(['members', 'tableFields']), ...mapState(['members', 'tableFields']),
...@@ -37,69 +39,72 @@ export default { ...@@ -37,69 +39,72 @@ export default {
</script> </script>
<template> <template>
<gl-table <div>
class="members-table" <gl-table
head-variant="white" class="members-table"
stacked="lg" head-variant="white"
:fields="filteredFields" stacked="lg"
:items="members" :fields="filteredFields"
primary-key="id" :items="members"
thead-class="border-bottom" primary-key="id"
:empty-text="__('No members found')" thead-class="border-bottom"
show-empty :empty-text="__('No members found')"
> show-empty
<template #cell(account)="{ item: member }"> >
<members-table-cell #default="{ memberType, isCurrentUser }" :member="member"> <template #cell(account)="{ item: member }">
<member-avatar <members-table-cell #default="{ memberType, isCurrentUser }" :member="member">
:member-type="memberType" <member-avatar
:is-current-user="isCurrentUser" :member-type="memberType"
:member="member" :is-current-user="isCurrentUser"
/> :member="member"
</members-table-cell> />
</template> </members-table-cell>
</template>
<template #cell(source)="{ item: member }"> <template #cell(source)="{ item: member }">
<members-table-cell #default="{ isDirectMember }" :member="member"> <members-table-cell #default="{ isDirectMember }" :member="member">
<member-source :is-direct-member="isDirectMember" :member-source="member.source" /> <member-source :is-direct-member="isDirectMember" :member-source="member.source" />
</members-table-cell> </members-table-cell>
</template> </template>
<template #cell(granted)="{ item: { createdAt, createdBy } }"> <template #cell(granted)="{ item: { createdAt, createdBy } }">
<created-at :date="createdAt" :created-by="createdBy" /> <created-at :date="createdAt" :created-by="createdBy" />
</template> </template>
<template #cell(invited)="{ item: { createdAt, createdBy } }"> <template #cell(invited)="{ item: { createdAt, createdBy } }">
<created-at :date="createdAt" :created-by="createdBy" /> <created-at :date="createdAt" :created-by="createdBy" />
</template> </template>
<template #cell(requested)="{ item: { createdAt } }"> <template #cell(requested)="{ item: { createdAt } }">
<created-at :date="createdAt" /> <created-at :date="createdAt" />
</template> </template>
<template #cell(expires)="{ item: { expiresAt } }"> <template #cell(expires)="{ item: { expiresAt } }">
<expires-at :date="expiresAt" /> <expires-at :date="expiresAt" />
</template> </template>
<template #cell(maxRole)="{ item: member }"> <template #cell(maxRole)="{ item: member }">
<members-table-cell #default="{ permissions }" :member="member"> <members-table-cell #default="{ permissions }" :member="member">
<role-dropdown v-if="permissions.canUpdate" :member="member" /> <role-dropdown v-if="permissions.canUpdate" :member="member" />
<gl-badge v-else>{{ member.accessLevel.stringValue }}</gl-badge> <gl-badge v-else>{{ member.accessLevel.stringValue }}</gl-badge>
</members-table-cell> </members-table-cell>
</template> </template>
<template #cell(actions)="{ item: member }"> <template #cell(actions)="{ item: member }">
<members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member"> <members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member">
<member-action-buttons <member-action-buttons
:member-type="memberType" :member-type="memberType"
:is-current-user="isCurrentUser" :is-current-user="isCurrentUser"
:permissions="permissions" :permissions="permissions"
:member="member" :member="member"
/> />
</members-table-cell> </members-table-cell>
</template> </template>
<template #head(actions)="{ label }"> <template #head(actions)="{ label }">
<span data-testid="col-actions" class="gl-sr-only">{{ label }}</span> <span data-testid="col-actions" class="gl-sr-only">{{ label }}</span>
</template> </template>
</gl-table> </gl-table>
<remove-group-link-modal />
</div>
</template> </template>
...@@ -15,3 +15,11 @@ export const updateMemberRole = async ({ state, commit }, { memberId, accessLeve ...@@ -15,3 +15,11 @@ export const updateMemberRole = async ({ state, commit }, { memberId, accessLeve
throw error; throw error;
} }
}; };
export const showRemoveGroupLinkModal = ({ commit }, groupLink) => {
commit(types.SHOW_REMOVE_GROUP_LINK_MODAL, groupLink);
};
export const hideRemoveGroupLinkModal = ({ commit }) => {
commit(types.HIDE_REMOVE_GROUP_LINK_MODAL);
};
...@@ -2,3 +2,6 @@ export const RECEIVE_MEMBER_ROLE_SUCCESS = 'RECEIVE_MEMBER_ROLE_SUCCESS'; ...@@ -2,3 +2,6 @@ export const RECEIVE_MEMBER_ROLE_SUCCESS = 'RECEIVE_MEMBER_ROLE_SUCCESS';
export const RECEIVE_MEMBER_ROLE_ERROR = 'RECEIVE_MEMBER_ROLE_ERROR'; export const RECEIVE_MEMBER_ROLE_ERROR = 'RECEIVE_MEMBER_ROLE_ERROR';
export const HIDE_ERROR = 'HIDE_ERROR'; export const HIDE_ERROR = 'HIDE_ERROR';
export const SHOW_REMOVE_GROUP_LINK_MODAL = 'SHOW_REMOVE_GROUP_LINK_MODAL';
export const HIDE_REMOVE_GROUP_LINK_MODAL = 'HIDE_REMOVE_GROUP_LINK_MODAL';
...@@ -23,4 +23,11 @@ export default { ...@@ -23,4 +23,11 @@ export default {
state.showError = false; state.showError = false;
state.errorMessage = ''; state.errorMessage = '';
}, },
[types.SHOW_REMOVE_GROUP_LINK_MODAL](state, groupLink) {
state.removeGroupLinkModalVisible = true;
state.groupLinkToRemove = groupLink;
},
[types.HIDE_REMOVE_GROUP_LINK_MODAL](state) {
state.removeGroupLinkModalVisible = false;
},
}; };
...@@ -14,4 +14,6 @@ export default ({ ...@@ -14,4 +14,6 @@ export default ({
requestFormatter, requestFormatter,
showError: false, showError: false,
errorMessage: '', errorMessage: '',
removeGroupLinkModalVisible: false,
groupLinkToRemove: null,
}); });
...@@ -33,6 +33,7 @@ module Groups::GroupMembersHelper ...@@ -33,6 +33,7 @@ module Groups::GroupMembersHelper
def linked_groups_list_data_attributes(group) def linked_groups_list_data_attributes(group)
{ {
members: linked_groups_data_json(group.shared_with_group_links), members: linked_groups_data_json(group.shared_with_group_links),
member_path: group_group_link_path(group, ':id'),
group_id: group.id group_id: group.id
} }
end end
......
...@@ -6,13 +6,14 @@ describe('initGroupMembersApp', () => { ...@@ -6,13 +6,14 @@ describe('initGroupMembersApp', () => {
let vm; let vm;
const createVm = () => { const createVm = () => {
vm = initGroupMembersApp(el, ['account']); vm = initGroupMembersApp(el, ['account'], () => ({}));
}; };
beforeEach(() => { beforeEach(() => {
el = document.createElement('div'); el = document.createElement('div');
el.setAttribute('data-members', membersJsonString); el.setAttribute('data-members', membersJsonString);
el.setAttribute('data-group-id', '234'); el.setAttribute('data-group-id', '234');
el.setAttribute('data-member-path', '/groups/foo-bar/-/group_members/:id');
el.setAttribute('data-ldap-override-path', '/groups/ldap-group/-/group_members/:id/override'); el.setAttribute('data-ldap-override-path', '/groups/ldap-group/-/group_members/:id/override');
}); });
......
...@@ -16175,6 +16175,9 @@ msgstr "" ...@@ -16175,6 +16175,9 @@ msgstr ""
msgid "Members|Are you sure you want to leave \"%{source}\"?" msgid "Members|Are you sure you want to leave \"%{source}\"?"
msgstr "" 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 %{usersName} from \"%{source}\""
msgstr "" msgstr ""
...@@ -16196,6 +16199,12 @@ msgstr "" ...@@ -16196,6 +16199,12 @@ msgstr ""
msgid "Members|No expiration set" msgid "Members|No expiration set"
msgstr "" msgstr ""
msgid "Members|Remove \"%{groupName}\""
msgstr ""
msgid "Members|Remove group"
msgstr ""
msgid "Members|Role updated successfully." msgid "Members|Role updated successfully."
msgstr "" msgstr ""
......
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlButton } from '@gitlab/ui';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import RemoveGroupLinkButton from '~/vue_shared/components/members/action_buttons/remove_group_link_button.vue';
import { group } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('RemoveGroupLinkButton', () => {
let wrapper;
const actions = {
showRemoveGroupLinkModal: jest.fn(),
};
const createStore = () => {
return new Vuex.Store({
actions,
});
};
const createComponent = () => {
wrapper = mount(RemoveGroupLinkButton, {
localVue,
store: createStore(),
propsData: {
groupLink: group,
},
directives: {
GlTooltip: createMockDirective(),
},
});
};
const findButton = () => wrapper.find(GlButton);
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('displays a tooltip', () => {
const button = findButton();
expect(getBinding(button.element, 'gl-tooltip')).not.toBeUndefined();
expect(button.attributes('title')).toBe('Remove group');
});
it('sets `aria-label` attribute', () => {
expect(findButton().attributes('aria-label')).toBe('Remove group');
});
it('calls Vuex action to open remove group link modal when clicked', () => {
findButton().trigger('click');
expect(actions.showRemoveGroupLinkModal).toHaveBeenCalledWith(expect.any(Object), group);
});
});
import { mount, createLocalVue, createWrapper } from '@vue/test-utils';
import { GlModal, GlForm } from '@gitlab/ui';
import { nextTick } from 'vue';
import { within } from '@testing-library/dom';
import Vuex from 'vuex';
import RemoveGroupLinkModal from '~/vue_shared/components/members/modals/remove_group_link_modal.vue';
import { REMOVE_GROUP_LINK_MODAL_ID } from '~/vue_shared/components/members/constants';
import { group } from '../mock_data';
jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
const localVue = createLocalVue();
localVue.use(Vuex);
describe('RemoveGroupLinkModal', () => {
let wrapper;
const actions = {
hideRemoveGroupLinkModal: jest.fn(),
};
const createStore = (state = {}) => {
return new Vuex.Store({
state: {
memberPath: '/groups/foo-bar/-/group_links/:id',
groupLinkToRemove: group,
removeGroupLinkModalVisible: true,
...state,
},
actions,
});
};
const createComponent = state => {
wrapper = mount(RemoveGroupLinkModal, {
localVue,
store: createStore(state),
attrs: {
static: true,
},
});
};
const findModal = () => wrapper.find(GlModal);
const findForm = () => findModal().find(GlForm);
const getByText = (text, options) =>
createWrapper(within(findModal().element).getByText(text, options));
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when modal is open', () => {
beforeEach(async () => {
createComponent();
await nextTick();
});
it('sets modal ID', () => {
expect(findModal().props('modalId')).toBe(REMOVE_GROUP_LINK_MODAL_ID);
});
it('displays modal title', () => {
expect(getByText(`Remove "${group.sharedWithGroup.fullName}"`).exists()).toBe(true);
});
it('displays modal body', () => {
expect(
getByText(`Are you sure you want to remove "${group.sharedWithGroup.fullName}"?`).exists(),
).toBe(true);
});
it('displays form with correct action and inputs', () => {
const form = findForm();
expect(form.attributes('action')).toBe(`/groups/foo-bar/-/group_links/${group.id}`);
expect(form.find('input[name="_method"]').attributes('value')).toBe('delete');
expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe(
'mock-csrf-token',
);
});
it('submits the form when "Remove group" button is clicked', () => {
const submitSpy = jest.spyOn(findForm().element, 'submit');
getByText('Remove group').trigger('click');
expect(submitSpy).toHaveBeenCalled();
submitSpy.mockRestore();
});
it('calls `hideRemoveGroupLinkModal` action when modal is closed', () => {
getByText('Cancel').trigger('click');
expect(actions.hideRemoveGroupLinkModal).toHaveBeenCalled();
});
});
it('modal does not show when `removeGroupLinkModalVisible` is `false`', () => {
createComponent({ removeGroupLinkModalVisible: false });
expect(findModal().vm.$attrs.visible).toBe(false);
});
});
...@@ -43,6 +43,7 @@ describe('MemberList', () => { ...@@ -43,6 +43,7 @@ describe('MemberList', () => {
'created-at', 'created-at',
'member-action-buttons', 'member-action-buttons',
'role-dropdown', 'role-dropdown',
'remove-group-link-modal',
], ],
}); });
}; };
......
import { noop } from 'lodash'; import { noop } from 'lodash';
import axios from 'axios'; import axios from 'axios';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { members } from 'jest/vue_shared/components/members/mock_data'; import { members, group } from 'jest/vue_shared/components/members/mock_data';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import httpStatusCodes from '~/lib/utils/http_status'; import httpStatusCodes from '~/lib/utils/http_status';
import * as types from '~/vuex_shared/modules/members/mutation_types'; import * as types from '~/vuex_shared/modules/members/mutation_types';
import { updateMemberRole } from '~/vuex_shared/modules/members/actions'; import {
updateMemberRole,
showRemoveGroupLinkModal,
hideRemoveGroupLinkModal,
} from '~/vuex_shared/modules/members/actions';
describe('Vuex members actions', () => { describe('Vuex members actions', () => {
let mock; let mock;
...@@ -30,6 +34,8 @@ describe('Vuex members actions', () => { ...@@ -30,6 +34,8 @@ describe('Vuex members actions', () => {
members, members,
memberPath: '/groups/foo-bar/-/group_members/:id', memberPath: '/groups/foo-bar/-/group_members/:id',
requestFormatter: noop, requestFormatter: noop,
removeGroupLinkModalVisible: false,
groupLinkToRemove: null,
}; };
describe('successful request', () => { describe('successful request', () => {
...@@ -73,4 +79,32 @@ describe('Vuex members actions', () => { ...@@ -73,4 +79,32 @@ describe('Vuex members actions', () => {
}); });
}); });
}); });
describe('Group Link Modal', () => {
const state = {
removeGroupLinkModalVisible: false,
groupLinkToRemove: null,
};
describe('showRemoveGroupLinkModal', () => {
it(`commits ${types.SHOW_REMOVE_GROUP_LINK_MODAL} mutation`, () => {
testAction(showRemoveGroupLinkModal, group, state, [
{
type: types.SHOW_REMOVE_GROUP_LINK_MODAL,
payload: group,
},
]);
});
});
describe('hideRemoveGroupLinkModal', () => {
it(`commits ${types.HIDE_REMOVE_GROUP_LINK_MODAL} mutation`, () => {
testAction(hideRemoveGroupLinkModal, group, state, [
{
type: types.HIDE_REMOVE_GROUP_LINK_MODAL,
},
]);
});
});
});
}); });
import { members } from 'jest/vue_shared/components/members/mock_data'; import { members, group } from 'jest/vue_shared/components/members/mock_data';
import mutations from '~/vuex_shared/modules/members/mutations'; import mutations from '~/vuex_shared/modules/members/mutations';
import * as types from '~/vuex_shared/modules/members/mutation_types'; import * as types from '~/vuex_shared/modules/members/mutation_types';
...@@ -59,4 +59,32 @@ describe('Vuex members mutations', () => { ...@@ -59,4 +59,32 @@ describe('Vuex members mutations', () => {
expect(state.errorMessage).toBe(''); expect(state.errorMessage).toBe('');
}); });
}); });
describe(types.SHOW_REMOVE_GROUP_LINK_MODAL, () => {
it('sets `removeGroupLinkModalVisible` and `groupLinkToRemove`', () => {
const state = {
removeGroupLinkModalVisible: false,
groupLinkToRemove: null,
};
mutations[types.SHOW_REMOVE_GROUP_LINK_MODAL](state, group);
expect(state).toEqual({
removeGroupLinkModalVisible: true,
groupLinkToRemove: group,
});
});
});
describe(types.HIDE_REMOVE_GROUP_LINK_MODAL, () => {
it('sets `removeGroupLinkModalVisible` to `false`', () => {
const state = {
removeGroupLinkModalVisible: false,
};
mutations[types.HIDE_REMOVE_GROUP_LINK_MODAL](state);
expect(state.removeGroupLinkModalVisible).toBe(false);
});
});
}); });
...@@ -88,9 +88,14 @@ RSpec.describe Groups::GroupMembersHelper do ...@@ -88,9 +88,14 @@ RSpec.describe Groups::GroupMembersHelper do
describe '#linked_groups_list_data_attributes' do describe '#linked_groups_list_data_attributes' do
include_context 'group_group_link' include_context 'group_group_link'
before do
allow(helper).to receive(:group_group_link_path).with(shared_group, ':id').and_return('/groups/foo-bar/-/group_links/:id')
end
it 'returns expected hash' do it 'returns expected hash' do
expect(helper.linked_groups_list_data_attributes(shared_group)).to include({ expect(helper.linked_groups_list_data_attributes(shared_group)).to include({
members: helper.linked_groups_data_json(shared_group.shared_with_group_links), members: helper.linked_groups_data_json(shared_group.shared_with_group_links),
member_path: '/groups/foo-bar/-/group_links/:id',
group_id: shared_group.id group_id: shared_group.id
}) })
end end
......
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