Commit 4d1d361a authored by Coung Ngo's avatar Coung Ngo Committed by cngo

Refactor `RemoveMemberModal` into members Vue app

Before this change, `RemoveMemberModal` was mounted on the page
separately from the Vue app. `RemoveMemberModal` listened for
clicks of the remove member button and read data from the button's
HTML dataset.

This change moves `RemoveMemberModal` into the Vue app. Clicking
the remove member button now directly opens `RemoveMemberModal`.
`RemoveMemberModal` reads data from the Vue app's Vuex store,
in keeping with the app's state architecture.
parent 510b8752
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { mapState } from 'vuex';
import { mapActions, mapState } from 'vuex';
export default {
name: 'RemoveMemberButton',
......@@ -45,7 +45,7 @@ export default {
oncallSchedules: {
type: Object,
required: false,
default: () => {},
default: () => ({}),
},
},
computed: {
......@@ -54,30 +54,35 @@ export default {
return state[this.namespace].memberPath;
},
}),
computedMemberPath() {
return this.memberPath.replace(':id', this.memberId);
modalData() {
return {
isAccessRequest: this.isAccessRequest,
isInvite: this.isInvite,
memberPath: this.memberPath.replace(':id', this.memberId),
memberType: this.memberType,
message: this.message,
oncallSchedules: this.oncallSchedules,
};
},
stringifiedSchedules() {
return JSON.stringify(this.oncallSchedules);
},
methods: {
...mapActions({
showRemoveMemberModal(dispatch, payload) {
return dispatch(`${this.namespace}/showRemoveMemberModal`, payload);
},
}),
},
};
</script>
<template>
<gl-button
v-gl-tooltip.hover
class="js-remove-member-button"
v-gl-tooltip
variant="danger"
:title="title"
:aria-label="title"
:icon="icon"
:data-member-path="computedMemberPath"
:data-member-type="memberType"
:data-is-access-request="isAccessRequest"
:data-is-invite="isInvite"
:data-message="message"
:data-oncall-schedules="stringifiedSchedules"
data-qa-selector="delete_member_button"
@click="showRemoveMemberModal(modalData)"
/>
</template>
<script>
import { GlFormCheckbox, GlModal } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { parseBoolean } from '~/lib/utils/common_utils';
import { mapActions, mapState } from 'vuex';
import csrf from '~/lib/utils/csrf';
import { s__, __ } from '~/locale';
import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
......@@ -16,20 +15,33 @@ export default {
GlModal,
OncallSchedulesList,
},
data() {
return {
modalData: {},
};
},
inject: ['namespace'],
computed: {
isAccessRequest() {
return parseBoolean(this.modalData.isAccessRequest);
...mapState({
isAccessRequest(state) {
return state[this.namespace].removeMemberModalData.isAccessRequest;
},
isInvite(state) {
return state[this.namespace].removeMemberModalData.isInvite;
},
memberPath(state) {
return state[this.namespace].removeMemberModalData.memberPath;
},
memberType(state) {
return state[this.namespace].removeMemberModalData.memberType;
},
message(state) {
return state[this.namespace].removeMemberModalData.message;
},
isInvite() {
return parseBoolean(this.modalData.isInvite);
oncallSchedules(state) {
return state[this.namespace].removeMemberModalData.oncallSchedules ?? {};
},
removeMemberModalVisible(state) {
return state[this.namespace].removeMemberModalVisible;
},
}),
isGroupMember() {
return this.modalData.memberType === 'GroupMember';
return this.memberType === 'GroupMember';
},
actionText() {
if (this.isAccessRequest) {
......@@ -54,29 +66,13 @@ export default {
isPartOfOncallSchedules() {
return !this.isAccessRequest && this.oncallSchedules.schedules?.length;
},
oncallSchedules() {
try {
return JSON.parse(this.modalData.oncallSchedules);
} catch (e) {
Sentry.captureException(e);
}
return {};
},
},
mounted() {
document.addEventListener('click', this.handleClick);
},
beforeDestroy() {
document.removeEventListener('click', this.handleClick);
},
methods: {
handleClick(event) {
const removeButton = event.target.closest('.js-remove-member-button');
if (removeButton) {
this.modalData = removeButton.dataset;
this.$refs.modal.show();
}
...mapActions({
hideRemoveMemberModal(dispatch) {
return dispatch(`${this.namespace}/hideRemoveMemberModal`);
},
}),
submitForm() {
this.$refs.form.submit();
},
......@@ -91,11 +87,13 @@ export default {
:action-cancel="$options.actionCancel"
:action-primary="actionPrimary"
:title="actionText"
:visible="removeMemberModalVisible"
data-qa-selector="remove_member_modal_content"
@primary="submitForm"
@hide="hideRemoveMemberModal"
>
<form ref="form" :action="modalData.memberPath" method="post">
<p data-testid="modal-message">{{ modalData.message }}</p>
<form ref="form" :action="memberPath" method="post">
<p>{{ message }}</p>
<oncall-schedules-list
v-if="isPartOfOncallSchedules"
......
......@@ -7,6 +7,7 @@ import { mergeUrlParams } from '~/lib/utils/url_utility';
import initUserPopovers from '~/user_popovers';
import { FIELDS, ACTIVE_TAB_QUERY_PARAM_NAME } from '../../constants';
import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue';
import RemoveMemberModal from '../modals/remove_member_modal.vue';
import CreatedAt from './created_at.vue';
import ExpirationDatepicker from './expiration_datepicker.vue';
import ExpiresAt from './expires_at.vue';
......@@ -29,6 +30,7 @@ export default {
MemberActionButtons,
RoleDropdown,
RemoveGroupLinkModal,
RemoveMemberModal,
ExpirationDatepicker,
LdapOverrideConfirmationModal: () =>
import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'),
......@@ -225,6 +227,7 @@ export default {
align="center"
/>
<remove-group-link-modal />
<remove-member-modal />
<ldap-override-confirmation-modal />
</div>
</template>
......@@ -25,6 +25,14 @@ export const hideRemoveGroupLinkModal = ({ commit }) => {
commit(types.HIDE_REMOVE_GROUP_LINK_MODAL);
};
export const showRemoveMemberModal = ({ commit }, modalData) => {
commit(types.SHOW_REMOVE_MEMBER_MODAL, modalData);
};
export const hideRemoveMemberModal = ({ commit }) => {
commit(types.HIDE_REMOVE_MEMBER_MODAL);
};
export const updateMemberExpiration = async ({ state, commit }, { memberId, expiresAt }) => {
try {
await axios.put(
......
......@@ -8,3 +8,6 @@ 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';
export const SHOW_REMOVE_MEMBER_MODAL = 'SHOW_REMOVE_MEMBER_MODAL';
export const HIDE_REMOVE_MEMBER_MODAL = 'HIDE_REMOVE_MEMBER_MODAL';
......@@ -47,4 +47,11 @@ export default {
[types.HIDE_REMOVE_GROUP_LINK_MODAL](state) {
state.removeGroupLinkModalVisible = false;
},
[types.SHOW_REMOVE_MEMBER_MODAL](state, modalData) {
state.removeMemberModalData = modalData;
state.removeMemberModalVisible = true;
},
[types.HIDE_REMOVE_MEMBER_MODAL](state) {
state.removeMemberModalVisible = false;
},
};
......@@ -20,4 +20,6 @@ export default ({
errorMessage: '',
removeGroupLinkModalVisible: false,
groupLinkToRemove: null,
removeMemberModalData: {},
removeMemberModalVisible: false,
});
import Vue from 'vue';
import UsersSelect from '~/users_select';
import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
function mountRemoveMemberModal() {
const el = document.querySelector('.js-remove-member-modal');
if (!el) {
return false;
}
return new Vue({
el,
render(createComponent) {
return createComponent(RemoveMemberModal);
},
});
}
document.addEventListener('DOMContentLoaded', () => {
mountRemoveMemberModal();
new UsersSelect(); // eslint-disable-line no-new
});
new UsersSelect(); // eslint-disable-line no-new
import Vue from 'vue';
import NamespaceSelect from '~/namespace_select';
import ProjectsList from '~/projects_list';
import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
function mountRemoveMemberModal() {
const el = document.querySelector('.js-remove-member-modal');
if (!el) {
return false;
}
return new Vue({
el,
render(createComponent) {
return createComponent(RemoveMemberModal);
},
});
}
mountRemoveMemberModal();
new ProjectsList(); // eslint-disable-line no-new
......
import Vue from 'vue';
import { groupMemberRequestFormatter } from '~/groups/members/utils';
import groupsSelect from '~/groups_select';
import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
......@@ -11,21 +10,6 @@ import { initMembersApp } from '~/members';
import { MEMBER_TYPES } from '~/members/constants';
import { groupLinkRequestFormatter } from '~/members/utils';
import UsersSelect from '~/users_select';
import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
function mountRemoveMemberModal() {
const el = document.querySelector('.js-remove-member-modal');
if (!el) {
return false;
}
return new Vue({
el,
render(createComponent) {
return createComponent(RemoveMemberModal);
},
});
}
const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions'];
......@@ -71,7 +55,6 @@ initMembersApp(document.querySelector('.js-group-members-list-app'), {
groupsSelect();
memberExpirationDate();
memberExpirationDate('.js-access-expiration-date-groups');
mountRemoveMemberModal();
initInviteMembersModal();
initInviteMembersTrigger();
initInviteGroupTrigger();
......
import Vue from 'vue';
import groupsSelect from '~/groups_select';
import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
import initInviteMembersForm from '~/invite_members/init_invite_members_form';
......@@ -11,26 +10,10 @@ import { MEMBER_TYPES } from '~/members/constants';
import { groupLinkRequestFormatter } from '~/members/utils';
import { projectMemberRequestFormatter } from '~/projects/members/utils';
import UsersSelect from '~/users_select';
import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
function mountRemoveMemberModal() {
const el = document.querySelector('.js-remove-member-modal');
if (!el) {
return false;
}
return new Vue({
el,
render(createComponent) {
return createComponent(RemoveMemberModal);
},
});
}
groupsSelect();
memberExpirationDate();
memberExpirationDate('.js-access-expiration-date-groups');
mountRemoveMemberModal();
initInviteMembersModal();
initInviteMembersTrigger();
initInviteGroupTrigger();
......
......@@ -4,7 +4,6 @@
- page_title @group.name, _("Groups")
- current_user_is_group_owner = @group && @group.has_owner?(current_user)
.js-remove-member-modal
%h3.page-title
= _('Group: %{group_name}') % { group_name: @group.full_name }
......
......@@ -5,7 +5,6 @@
- @content_class = "admin-projects"
- current_user_is_group_owner = @group && @group.has_owner?(current_user)
.js-remove-member-modal
%h3.page-title
= _('Project: %{name}') % { name: @project.full_name }
= link_to edit_project_path(@project), class: "btn btn-default gl-button float-right" do
......
......@@ -2,7 +2,6 @@
- page_title _('Group members')
- groups_select_tag_data = group_select_data(@group).merge({ skip_groups: @skip_groups })
.js-remove-member-modal
.row.gl-mt-3
.col-lg-12
.gl-display-flex.gl-flex-wrap
......
- add_page_specific_style 'page_bundles/members'
- page_title _("Members")
.js-remove-member-modal
.row.gl-mt-3
.col-lg-12
- if can_invite_members_for_project?(@project) || can_invite_group_for_project?(@project)
......
......@@ -48,6 +48,7 @@ describe('MemberList', () => {
'member-action-buttons',
'role-dropdown',
'remove-group-link-modal',
'remove-member-modal',
'expiration-datepicker',
],
});
......
......@@ -7,7 +7,7 @@ module QA
include Page::Component::InviteMembersModal
include Page::Component::UsersSelect
view 'app/assets/javascripts/vue_shared/components/remove_member_modal.vue' do
view 'app/assets/javascripts/members/components/modals/remove_member_modal.vue' do
element :remove_member_modal_content
end
......
......@@ -37,7 +37,7 @@ describe('InviteActionButtons', () => {
});
it('sets props correctly', () => {
expect(findRemoveMemberButton().props()).toEqual({
expect(findRemoveMemberButton().props()).toMatchObject({
memberId: member.id,
memberType: null,
message: `Are you sure you want to revoke the invitation for ${member.invite.email} to join "${member.source.fullName}"`,
......
import { GlButton } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { modalData } from 'jest/members/mock_data';
import RemoveMemberButton from '~/members/components/action_buttons/remove_member_button.vue';
import { MEMBER_TYPES } from '~/members/constants';
......@@ -10,6 +12,10 @@ localVue.use(Vuex);
describe('RemoveMemberButton', () => {
let wrapper;
const actions = {
showRemoveMemberModal: jest.fn(),
};
const createStore = (state = {}) => {
return new Vuex.Store({
modules: {
......@@ -19,6 +25,7 @@ describe('RemoveMemberButton', () => {
memberPath: '/groups/foo-bar/-/group_members/:id',
...state,
},
actions,
},
},
});
......@@ -47,20 +54,16 @@ describe('RemoveMemberButton', () => {
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('sets attributes on button', () => {
createComponent();
expect(wrapper.attributes()).toMatchObject({
'data-member-path': '/groups/foo-bar/-/group_members/1',
'data-member-type': 'GroupMember',
'data-message': 'Are you sure you want to remove John Smith?',
'data-is-access-request': 'true',
'data-is-invite': 'true',
'data-oncall-schedules': '{"name":"user","schedules":[]}',
'aria-label': 'Remove member',
title: 'Remove member',
icon: 'remove',
......@@ -68,14 +71,12 @@ describe('RemoveMemberButton', () => {
});
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();
it('calls Vuex action to show `remove member` modal when clicked', () => {
wrapper.findComponent(GlButton).vm.$emit('click');
expect(wrapper.classes()).toContain('js-remove-member-button');
expect(actions.showRemoveMemberModal).toHaveBeenCalledWith(expect.any(Object), modalData);
});
});
import { GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import RemoveMemberModal from '~/members/components/modals/remove_member_modal.vue';
import { MEMBER_TYPES } from '~/members/constants';
import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
const mockSchedules = JSON.stringify({
schedules: [
{
id: 1,
name: 'Schedule 1',
},
],
name: 'User1',
});
Vue.use(Vuex);
describe('RemoveMemberModal', () => {
const memberPath = '/gitlab-org/gitlab-test/-/project_members/90';
const mockSchedules = {
name: 'User1',
schedules: [{ id: 1, name: 'Schedule 1' }],
};
let wrapper;
const actions = {
hideRemoveMemberModal: jest.fn(),
};
const createStore = (removeMemberModalData) =>
new Vuex.Store({
modules: {
[MEMBER_TYPES.user]: {
namespaced: true,
state: {
removeMemberModalData,
},
actions,
},
},
});
const createComponent = (state) => {
wrapper = shallowMount(RemoveMemberModal, {
store: createStore(state),
provide: {
namespace: MEMBER_TYPES.user,
},
});
};
const findForm = () => wrapper.find({ ref: 'form' });
const findGlModal = () => wrapper.findComponent(GlModal);
const findOnCallSchedulesList = () => wrapper.findComponent(OncallSchedulesList);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe.each`
state | memberType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | onCallSchedules
${'removing a group member'} | ${'GroupMember'} | ${false} | ${'false'} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${`{}`}
${'removing a project member'} | ${'ProjectMember'} | ${false} | ${'false'} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${mockSchedules}
${'denying an access request'} | ${'ProjectMember'} | ${true} | ${'false'} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} | ${`{}`}
${'revoking invite'} | ${'ProjectMember'} | ${false} | ${'true'} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'} | ${mockSchedules}
${'removing a group member'} | ${'GroupMember'} | ${false} | ${false} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${{}}
${'removing a project member'} | ${'ProjectMember'} | ${false} | ${false} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${mockSchedules}
${'denying an access request'} | ${'ProjectMember'} | ${true} | ${false} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} | ${{}}
${'revoking invite'} | ${'ProjectMember'} | ${false} | ${true} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'} | ${mockSchedules}
`(
'when $state',
({
......@@ -45,24 +69,17 @@ describe('RemoveMemberModal', () => {
onCallSchedules,
}) => {
beforeEach(() => {
wrapper = shallowMount(RemoveMemberModal, {
data() {
return {
modalData: {
createComponent({
isAccessRequest,
isInvite,
message,
memberPath,
memberType,
onCallSchedules,
},
};
},
});
});
const parsedSchedules = JSON.parse(onCallSchedules);
const isPartOfOncallSchedules = Boolean(isAccessRequest && parsedSchedules.schedules?.length);
const isPartOfOncallSchedules = Boolean(isAccessRequest && onCallSchedules.schedules?.length);
it(`has the title ${actionText}`, () => {
expect(findGlModal().attributes('title')).toBe(actionText);
......@@ -73,7 +90,7 @@ describe('RemoveMemberModal', () => {
});
it('displays a message to the user', () => {
expect(wrapper.find('[data-testid=modal-message]').text()).toBe(message);
expect(wrapper.find('p').text()).toBe(message);
});
it(`shows ${
......@@ -105,6 +122,12 @@ describe('RemoveMemberModal', () => {
spy.mockRestore();
});
it('calls Vuex action to hide the modal when `GlModal` emits `hide` event', () => {
findGlModal().vm.$emit('hide');
expect(actions.hideRemoveMemberModal).toHaveBeenCalled();
});
},
);
});
......@@ -72,6 +72,7 @@ describe('MembersTable', () => {
'member-action-buttons',
'role-dropdown',
'remove-group-link-modal',
'remove-member-modal',
'expiration-datepicker',
],
});
......
......@@ -57,6 +57,15 @@ export const group = {
validRoles: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 },
};
export const modalData = {
isAccessRequest: true,
isInvite: true,
memberPath: '/groups/foo-bar/-/group_members/1',
memberType: 'GroupMember',
message: 'Are you sure you want to remove John Smith?',
oncallSchedules: { name: 'user', schedules: [] },
};
const { user, ...memberNoUser } = member;
export const invite = {
...memberNoUser,
......
......@@ -3,12 +3,14 @@ import MockAdapter from 'axios-mock-adapter';
import { noop } from 'lodash';
import { useFakeDate } from 'helpers/fake_date';
import testAction from 'helpers/vuex_action_helper';
import { members, group } from 'jest/members/mock_data';
import { members, group, modalData } from 'jest/members/mock_data';
import httpStatusCodes from '~/lib/utils/http_status';
import {
updateMemberRole,
showRemoveGroupLinkModal,
hideRemoveGroupLinkModal,
showRemoveMemberModal,
hideRemoveMemberModal,
updateMemberExpiration,
} from '~/members/store/actions';
import * as types from '~/members/store/mutation_types';
......@@ -153,4 +155,32 @@ describe('Vuex members actions', () => {
});
});
});
describe('Remove member modal', () => {
const state = {
removeMemberModalVisible: false,
removeMemberModalData: {},
};
describe('showRemoveMemberModal', () => {
it(`commits ${types.SHOW_REMOVE_MEMBER_MODAL} mutation`, () => {
testAction(showRemoveMemberModal, modalData, state, [
{
type: types.SHOW_REMOVE_MEMBER_MODAL,
payload: modalData,
},
]);
});
});
describe('hideRemoveMemberModal', () => {
it(`commits ${types.HIDE_REMOVE_MEMBER_MODAL} mutation`, () => {
testAction(hideRemoveMemberModal, undefined, state, [
{
type: types.HIDE_REMOVE_MEMBER_MODAL,
},
]);
});
});
});
});
import { members, group } from 'jest/members/mock_data';
import { members, group, modalData } from 'jest/members/mock_data';
import * as types from '~/members/store/mutation_types';
import mutations from '~/members/store/mutations';
......@@ -154,4 +154,32 @@ describe('Vuex members mutations', () => {
expect(state.removeGroupLinkModalVisible).toBe(false);
});
});
describe(types.SHOW_REMOVE_MEMBER_MODAL, () => {
it('sets `removeMemberModalVisible` and `removeMemberModalData`', () => {
const state = {
removeMemberModalVisible: false,
removeMemberModalData: {},
};
mutations[types.SHOW_REMOVE_MEMBER_MODAL](state, modalData);
expect(state).toEqual({
removeMemberModalVisible: true,
removeMemberModalData: modalData,
});
});
});
describe(types.HIDE_REMOVE_MEMBER_MODAL, () => {
it('sets `removeMemberModalVisible` to `false`', () => {
const state = {
removeMemberModalVisible: true,
};
mutations[types.HIDE_REMOVE_MEMBER_MODAL](state);
expect(state.removeMemberModalVisible).toBe(false);
});
});
});
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