Commit e28ecc7d authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch...

Merge branch '235603-convert-group-members-list-view-from-haml-to-vue-remove-group-link-button' into 'master'

Group members Vue conversion - Add remove group link button

See merge request gitlab-org/gitlab!45202
parents aba90b6a 01cf7d45
<script>
import ActionButtonGroup from './action_button_group.vue';
import RemoveGroupLinkButton from './remove_group_link_button.vue';
export default {
name: 'GroupActionButtons',
components: { ActionButtonGroup, RemoveGroupLinkButton },
props: {
member: {
type: Object,
required: true,
},
permissions: {
type: Object,
required: true,
},
},
};
</script>
<template>
<span>
<!-- Temporarily empty -->
</span>
<action-button-group>
<div v-if="permissions.canRemove" class="gl-px-1">
<remove-group-link-button :group-link="member" />
</div>
</action-button-group>
</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 = {
export const DAYS_TO_EXPIRE_SOON = 7;
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';
import MemberActionButtons from './member_action_buttons.vue';
import MembersTableCell from './members_table_cell.vue';
import RoleDropdown from './role_dropdown.vue';
import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue';
export default {
name: 'MembersTable',
......@@ -23,6 +24,7 @@ export default {
MemberSource,
MemberActionButtons,
RoleDropdown,
RemoveGroupLinkModal,
},
computed: {
...mapState(['members', 'tableFields']),
......@@ -37,69 +39,72 @@ export default {
</script>
<template>
<gl-table
class="members-table"
head-variant="white"
stacked="lg"
:fields="filteredFields"
:items="members"
primary-key="id"
thead-class="border-bottom"
:empty-text="__('No members found')"
show-empty
>
<template #cell(account)="{ item: member }">
<members-table-cell #default="{ memberType, isCurrentUser }" :member="member">
<member-avatar
:member-type="memberType"
:is-current-user="isCurrentUser"
:member="member"
/>
</members-table-cell>
</template>
<div>
<gl-table
class="members-table"
head-variant="white"
stacked="lg"
:fields="filteredFields"
:items="members"
primary-key="id"
thead-class="border-bottom"
:empty-text="__('No members found')"
show-empty
>
<template #cell(account)="{ item: member }">
<members-table-cell #default="{ memberType, isCurrentUser }" :member="member">
<member-avatar
:member-type="memberType"
:is-current-user="isCurrentUser"
:member="member"
/>
</members-table-cell>
</template>
<template #cell(source)="{ item: member }">
<members-table-cell #default="{ isDirectMember }" :member="member">
<member-source :is-direct-member="isDirectMember" :member-source="member.source" />
</members-table-cell>
</template>
<template #cell(source)="{ item: member }">
<members-table-cell #default="{ isDirectMember }" :member="member">
<member-source :is-direct-member="isDirectMember" :member-source="member.source" />
</members-table-cell>
</template>
<template #cell(granted)="{ item: { createdAt, createdBy } }">
<created-at :date="createdAt" :created-by="createdBy" />
</template>
<template #cell(granted)="{ item: { createdAt, createdBy } }">
<created-at :date="createdAt" :created-by="createdBy" />
</template>
<template #cell(invited)="{ item: { createdAt, createdBy } }">
<created-at :date="createdAt" :created-by="createdBy" />
</template>
<template #cell(invited)="{ item: { createdAt, createdBy } }">
<created-at :date="createdAt" :created-by="createdBy" />
</template>
<template #cell(requested)="{ item: { createdAt } }">
<created-at :date="createdAt" />
</template>
<template #cell(requested)="{ item: { createdAt } }">
<created-at :date="createdAt" />
</template>
<template #cell(expires)="{ item: { expiresAt } }">
<expires-at :date="expiresAt" />
</template>
<template #cell(expires)="{ item: { expiresAt } }">
<expires-at :date="expiresAt" />
</template>
<template #cell(maxRole)="{ item: member }">
<members-table-cell #default="{ permissions }" :member="member">
<role-dropdown v-if="permissions.canUpdate" :member="member" />
<gl-badge v-else>{{ member.accessLevel.stringValue }}</gl-badge>
</members-table-cell>
</template>
<template #cell(maxRole)="{ item: member }">
<members-table-cell #default="{ permissions }" :member="member">
<role-dropdown v-if="permissions.canUpdate" :member="member" />
<gl-badge v-else>{{ member.accessLevel.stringValue }}</gl-badge>
</members-table-cell>
</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 #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>
</gl-table>
<template #head(actions)="{ label }">
<span data-testid="col-actions" class="gl-sr-only">{{ label }}</span>
</template>
</gl-table>
<remove-group-link-modal />
</div>
</template>
......@@ -15,3 +15,11 @@ export const updateMemberRole = async ({ state, commit }, { memberId, accessLeve
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';
export const RECEIVE_MEMBER_ROLE_ERROR = 'RECEIVE_MEMBER_ROLE_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 {
state.showError = false;
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 ({
requestFormatter,
showError: false,
errorMessage: '',
removeGroupLinkModalVisible: false,
groupLinkToRemove: null,
});
......@@ -33,6 +33,7 @@ module Groups::GroupMembersHelper
def linked_groups_list_data_attributes(group)
{
members: linked_groups_data_json(group.shared_with_group_links),
member_path: group_group_link_path(group, ':id'),
group_id: group.id
}
end
......
......@@ -6,13 +6,14 @@ describe('initGroupMembersApp', () => {
let vm;
const createVm = () => {
vm = initGroupMembersApp(el, ['account']);
vm = initGroupMembersApp(el, ['account'], () => ({}));
};
beforeEach(() => {
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');
el.setAttribute('data-ldap-override-path', '/groups/ldap-group/-/group_members/:id/override');
});
......
......@@ -16193,6 +16193,9 @@ msgstr ""
msgid "Members|Are you sure you want to leave \"%{source}\"?"
msgstr ""
msgid "Members|Are you sure you want to remove \"%{groupName}\"?"
msgstr ""
msgid "Members|Are you sure you want to remove %{usersName} from \"%{source}\""
msgstr ""
......@@ -16214,6 +16217,12 @@ msgstr ""
msgid "Members|No expiration set"
msgstr ""
msgid "Members|Remove \"%{groupName}\""
msgstr ""
msgid "Members|Remove group"
msgstr ""
msgid "Members|Role updated successfully."
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', () => {
'created-at',
'member-action-buttons',
'role-dropdown',
'remove-group-link-modal',
],
});
};
......
import { noop } from 'lodash';
import axios from 'axios';
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 httpStatusCodes from '~/lib/utils/http_status';
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', () => {
let mock;
......@@ -30,6 +34,8 @@ describe('Vuex members actions', () => {
members,
memberPath: '/groups/foo-bar/-/group_members/:id',
requestFormatter: noop,
removeGroupLinkModalVisible: false,
groupLinkToRemove: null,
};
describe('successful request', () => {
......@@ -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 * as types from '~/vuex_shared/modules/members/mutation_types';
......@@ -59,4 +59,32 @@ describe('Vuex members mutations', () => {
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
describe '#linked_groups_list_data_attributes' do
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
expect(helper.linked_groups_list_data_attributes(shared_group)).to include({
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
})
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