Commit 79553595 authored by Vijay Hawoldar's avatar Vijay Hawoldar Committed by Andrew Fontaine

Display error if remove invalid billable member

Some billable members can be associated via group invites, which makes
them invalid for removal from the group being viewed. This will now
trigger an error modal to inform the customer

Changelog: changed
parent 093bee4c
...@@ -86,7 +86,7 @@ export default { ...@@ -86,7 +86,7 @@ export default {
data-qa-selector="remove_billable_member_modal" data-qa-selector="remove_billable_member_modal"
:ok-disabled="!canSubmit" :ok-disabled="!canSubmit"
@primary="removeBillableMember" @primary="removeBillableMember"
@canceled="setBillableMemberToRemove(null)" @hide="setBillableMemberToRemove(null)"
> >
<p> <p>
<gl-sprintf :message="modalText"> <gl-sprintf :message="modalText">
......
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
GlButton, GlButton,
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
GlModal,
GlModalDirective, GlModalDirective,
GlIcon, GlIcon,
GlPagination, GlPagination,
...@@ -20,6 +21,9 @@ import { ...@@ -20,6 +21,9 @@ import {
AVATAR_SIZE, AVATAR_SIZE,
SEARCH_DEBOUNCE_MS, SEARCH_DEBOUNCE_MS,
REMOVE_BILLABLE_MEMBER_MODAL_ID, REMOVE_BILLABLE_MEMBER_MODAL_ID,
CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_ID,
CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_TITLE,
CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_CONTENT,
} from 'ee/billings/seat_usage/constants'; } from 'ee/billings/seat_usage/constants';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import RemoveBillableMemberModal from './remove_billable_member_modal.vue'; import RemoveBillableMemberModal from './remove_billable_member_modal.vue';
...@@ -37,6 +41,7 @@ export default { ...@@ -37,6 +41,7 @@ export default {
GlButton, GlButton,
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
GlModal,
GlIcon, GlIcon,
GlPagination, GlPagination,
GlSearchBoxByType, GlSearchBoxByType,
...@@ -118,6 +123,13 @@ export default { ...@@ -118,6 +123,13 @@ export default {
this.resetBillableMembers(); this.resetBillableMembers();
} }
}, },
displayRemoveMemberModal(user) {
if (user.removable) {
this.setBillableMemberToRemove(user);
} else {
this.$refs.cannotRemoveModal.show();
}
},
}, },
i18n: { i18n: {
emailNotVisibleTooltipText: s__( emailNotVisibleTooltipText: s__(
...@@ -127,6 +139,9 @@ export default { ...@@ -127,6 +139,9 @@ export default {
avatarSize: AVATAR_SIZE, avatarSize: AVATAR_SIZE,
fields: FIELDS, fields: FIELDS,
removeBillableMemberModalId: REMOVE_BILLABLE_MEMBER_MODAL_ID, removeBillableMemberModalId: REMOVE_BILLABLE_MEMBER_MODAL_ID,
cannotRemoveModalId: CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_ID,
cannotRemoveModalTitle: CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_TITLE,
cannotRemoveModalText: CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_CONTENT,
}; };
</script> </script>
...@@ -218,7 +233,8 @@ export default { ...@@ -218,7 +233,8 @@ export default {
<gl-dropdown icon="ellipsis_h" right data-testid="user-actions"> <gl-dropdown icon="ellipsis_h" right data-testid="user-actions">
<gl-dropdown-item <gl-dropdown-item
v-gl-modal="$options.removeBillableMemberModalId" v-gl-modal="$options.removeBillableMemberModalId"
@click="setBillableMemberToRemove(data.item.user)" data-testid="remove-user"
@click="displayRemoveMemberModal(data.item.user)"
> >
{{ __('Remove user') }} {{ __('Remove user') }}
</gl-dropdown-item> </gl-dropdown-item>
...@@ -243,5 +259,17 @@ export default { ...@@ -243,5 +259,17 @@ export default {
v-if="billableMemberToRemove" v-if="billableMemberToRemove"
:modal-id="$options.removeBillableMemberModalId" :modal-id="$options.removeBillableMemberModalId"
/> />
<gl-modal
ref="cannotRemoveModal"
:modal-id="$options.cannotRemoveModalId"
:title="$options.cannotRemoveModalTitle"
:action-primary="{ text: __('Okay') }"
static
>
<p>
{{ $options.cannotRemoveModalText }}
</p>
</gl-modal>
</section> </section>
</template> </template>
...@@ -35,6 +35,12 @@ export const DETAILS_FIELDS = [ ...@@ -35,6 +35,12 @@ export const DETAILS_FIELDS = [
{ key: 'role', label: __('Role'), thClass: thWidthClass(40) }, { key: 'role', label: __('Role'), thClass: thWidthClass(40) },
]; ];
export const CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_ID = 'cannot-remove-member-modal';
export const CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_TITLE = s__('Billing|Cannot remove user');
export const CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_CONTENT = s__(
`Billing|Members who were invited via a group invitation cannot be removed.
You can either remove the entire group, or ask an Owner of the invited group to remove the member.`,
);
export const REMOVE_BILLABLE_MEMBER_MODAL_ID = 'billable-member-remove-modal'; export const REMOVE_BILLABLE_MEMBER_MODAL_ID = 'billable-member-remove-modal';
export const REMOVE_BILLABLE_MEMBER_MODAL_CONTENT_TEXT_TEMPLATE = s__( export const REMOVE_BILLABLE_MEMBER_MODAL_CONTENT_TEXT_TEMPLATE = s__(
`Billing|You are about to remove user %{username} from your subscription. `Billing|You are about to remove user %{username} from your subscription.
......
export const tableItems = (state) => { export const tableItems = (state) => {
if (state.members.length) { return (state.members ?? []).map(({ email, ...member }) => ({
return state.members.map(
({ id, name, username, avatar_url, web_url, email, last_activity_on, membership_type }) => {
const formattedUserName = `@${username}`;
return {
user: { user: {
id, ...member,
name, username: `@${member.username}`,
username: formattedUserName,
avatar_url,
web_url,
last_activity_on,
membership_type,
}, },
email, email,
}; }));
},
);
}
return [];
}; };
export const membershipsById = (state) => (memberId) => { export const membershipsById = (state) => (memberId) => {
......
---
title: Add a new error modal for billable member removal
merge_request: 61509
author:
type: changed
...@@ -8,6 +8,8 @@ RSpec.describe 'Groups > Billing > Seat Usage', :js do ...@@ -8,6 +8,8 @@ RSpec.describe 'Groups > Billing > Seat Usage', :js do
let_it_be(:sub_group) { create(:group, parent: group) } let_it_be(:sub_group) { create(:group, parent: group) }
let_it_be(:maintainer) { create(:user) } let_it_be(:maintainer) { create(:user) }
let_it_be(:user_from_sub_group) { create(:user) } let_it_be(:user_from_sub_group) { create(:user) }
let_it_be(:shared_group) { create(:group) }
let_it_be(:shared_group_developer) { create(:user) }
before do before do
allow(Gitlab).to receive(:com?).and_return(true) allow(Gitlab).to receive(:com?).and_return(true)
...@@ -18,6 +20,9 @@ RSpec.describe 'Groups > Billing > Seat Usage', :js do ...@@ -18,6 +20,9 @@ RSpec.describe 'Groups > Billing > Seat Usage', :js do
sub_group.add_maintainer(user_from_sub_group) sub_group.add_maintainer(user_from_sub_group)
shared_group.add_developer(shared_group_developer)
create(:group_group_link, { shared_with_group: shared_group, shared_group: group })
sign_in(user) sign_in(user)
visit group_seat_usage_path(group) visit group_seat_usage_path(group)
...@@ -27,7 +32,7 @@ RSpec.describe 'Groups > Billing > Seat Usage', :js do ...@@ -27,7 +32,7 @@ RSpec.describe 'Groups > Billing > Seat Usage', :js do
context 'seat usage table' do context 'seat usage table' do
it 'displays correct number of users' do it 'displays correct number of users' do
within '[data-testid="table"]' do within '[data-testid="table"]' do
expect(all('tbody tr').count).to eq(3) expect(all('tbody tr').count).to eq(4)
end end
end end
...@@ -92,6 +97,10 @@ RSpec.describe 'Groups > Billing > Seat Usage', :js do ...@@ -92,6 +97,10 @@ RSpec.describe 'Groups > Billing > Seat Usage', :js do
expect(page).to have_button('Remove user', disabled: true) expect(page).to have_button('Remove user', disabled: true)
end end
end end
it 'does not display the error modal' do
expect(page).not_to have_content('Cannot remove user')
end
end end
context 'removing the user' do context 'removing the user' do
...@@ -113,7 +122,7 @@ RSpec.describe 'Groups > Billing > Seat Usage', :js do ...@@ -113,7 +122,7 @@ RSpec.describe 'Groups > Billing > Seat Usage', :js do
wait_for_all_requests wait_for_all_requests
within '[data-testid="table"]' do within '[data-testid="table"]' do
expect(all('tbody tr').count).to eq(2) expect(all('tbody tr').count).to eq(3)
end end
expect(page.find('.flash-container')).to have_content('User was successfully removed') expect(page.find('.flash-container')).to have_content('User was successfully removed')
...@@ -122,7 +131,7 @@ RSpec.describe 'Groups > Billing > Seat Usage', :js do ...@@ -122,7 +131,7 @@ RSpec.describe 'Groups > Billing > Seat Usage', :js do
context 'removing the user from a sub-group' do context 'removing the user from a sub-group' do
it 'updates the seat table of the parent group' do it 'updates the seat table of the parent group' do
within '[data-testid="table"]' do within '[data-testid="table"]' do
expect(all('tbody tr').count).to eq(3) expect(all('tbody tr').count).to eq(4)
end end
visit group_group_members_path(sub_group) visit group_group_members_path(sub_group)
...@@ -140,9 +149,26 @@ RSpec.describe 'Groups > Billing > Seat Usage', :js do ...@@ -140,9 +149,26 @@ RSpec.describe 'Groups > Billing > Seat Usage', :js do
wait_for_all_requests wait_for_all_requests
within '[data-testid="table"]' do within '[data-testid="table"]' do
expect(all('tbody tr').count).to eq(2) expect(all('tbody tr').count).to eq(3)
end
end
end
end
context 'when cannot remove the user' do
let(:shared_user_row) do
within '[data-testid="table"]' do
find('tr', text: shared_group_developer.name)
end end
end end
it 'displays an error modal' do
within shared_user_row do
find('[data-testid="user-actions"]').click
click_button 'Remove user'
end
expect(page).to have_content('Cannot remove user')
end end
end end
end end
......
...@@ -76,6 +76,7 @@ export const mockDataSeats = { ...@@ -76,6 +76,7 @@ export const mockDataSeats = {
email: 'administrator@email.com', email: 'administrator@email.com',
last_activity_on: '2020-03-01', last_activity_on: '2020-03-01',
membership_type: 'group_member', membership_type: 'group_member',
removable: true,
}, },
{ {
id: 3, id: 3,
...@@ -86,6 +87,7 @@ export const mockDataSeats = { ...@@ -86,6 +87,7 @@ export const mockDataSeats = {
email: 'agustin_walker@email.com', email: 'agustin_walker@email.com',
last_activity_on: '2020-03-01', last_activity_on: '2020-03-01',
membership_type: 'group_member', membership_type: 'group_member',
removable: true,
}, },
{ {
id: 4, id: 4,
...@@ -96,6 +98,7 @@ export const mockDataSeats = { ...@@ -96,6 +98,7 @@ export const mockDataSeats = {
last_activity_on: null, last_activity_on: null,
email: null, email: null,
membership_type: 'group_invite', membership_type: 'group_invite',
removable: false,
}, },
], ],
headers: { headers: {
...@@ -127,6 +130,7 @@ export const mockTableItems = [ ...@@ -127,6 +130,7 @@ export const mockTableItems = [
web_url: 'path/to/administrator', web_url: 'path/to/administrator',
last_activity_on: '2020-03-01', last_activity_on: '2020-03-01',
membership_type: 'group_member', membership_type: 'group_member',
removable: true,
}, },
}, },
{ {
...@@ -139,6 +143,7 @@ export const mockTableItems = [ ...@@ -139,6 +143,7 @@ export const mockTableItems = [
web_url: 'path/to/agustin_walker', web_url: 'path/to/agustin_walker',
last_activity_on: '2020-03-01', last_activity_on: '2020-03-01',
membership_type: 'group_member', membership_type: 'group_member',
removable: true,
}, },
}, },
{ {
...@@ -151,6 +156,7 @@ export const mockTableItems = [ ...@@ -151,6 +156,7 @@ export const mockTableItems = [
web_url: 'path/to/joella_miller', web_url: 'path/to/joella_miller',
last_activity_on: null, last_activity_on: null,
membership_type: 'group_invite', membership_type: 'group_invite',
removable: false,
}, },
}, },
]; ];
...@@ -6,11 +6,14 @@ import { ...@@ -6,11 +6,14 @@ import {
GlAvatarLabeled, GlAvatarLabeled,
GlSearchBoxByType, GlSearchBoxByType,
GlBadge, GlBadge,
GlModal,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { mount, shallowMount, createLocalVue } from '@vue/test-utils'; import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import SubscriptionSeats from 'ee/billings/seat_usage/components/subscription_seats.vue'; import SubscriptionSeats from 'ee/billings/seat_usage/components/subscription_seats.vue';
import { CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_CONTENT } from 'ee/billings/seat_usage/constants';
import { mockDataSeats, mockTableItems } from 'ee_jest/billings/mock_data'; import { mockDataSeats, mockTableItems } from 'ee_jest/billings/mock_data';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -18,6 +21,7 @@ localVue.use(Vuex); ...@@ -18,6 +21,7 @@ localVue.use(Vuex);
const actionSpies = { const actionSpies = {
fetchBillableMembersList: jest.fn(), fetchBillableMembersList: jest.fn(),
resetBillableMembers: jest.fn(), resetBillableMembers: jest.fn(),
setBillableMemberToRemove: jest.fn(),
}; };
const providedFields = { const providedFields = {
...@@ -53,10 +57,12 @@ describe('Subscription Seats', () => { ...@@ -53,10 +57,12 @@ describe('Subscription Seats', () => {
mountFn = shallowMount, mountFn = shallowMount,
initialGetters = {}, initialGetters = {},
} = {}) => { } = {}) => {
return mountFn(SubscriptionSeats, { return extendedWrapper(
mountFn(SubscriptionSeats, {
store: fakeStore({ initialState, initialGetters }), store: fakeStore({ initialState, initialGetters }),
localVue, localVue,
}); }),
);
}; };
const findTable = () => wrapper.find(GlTable); const findTable = () => wrapper.find(GlTable);
...@@ -69,6 +75,9 @@ describe('Subscription Seats', () => { ...@@ -69,6 +75,9 @@ describe('Subscription Seats', () => {
const findSearchBox = () => wrapper.find(GlSearchBoxByType); const findSearchBox = () => wrapper.find(GlSearchBoxByType);
const findPagination = () => wrapper.find(GlPagination); const findPagination = () => wrapper.find(GlPagination);
const findAllRemoveUserItems = () => wrapper.findAllByTestId('remove-user');
const findErrorModal = () => wrapper.findComponent(GlModal);
const serializeUser = (rowWrapper) => { const serializeUser = (rowWrapper) => {
const avatarLink = rowWrapper.find(GlAvatarLink); const avatarLink = rowWrapper.find(GlAvatarLink);
const avatarLabeled = rowWrapper.find(GlAvatarLabeled); const avatarLabeled = rowWrapper.find(GlAvatarLabeled);
...@@ -152,6 +161,20 @@ describe('Subscription Seats', () => { ...@@ -152,6 +161,20 @@ describe('Subscription Seats', () => {
totalItems: 300, totalItems: 300,
}); });
}); });
describe('with error modal', () => {
it('does not render the model if the user is not removable', async () => {
await findAllRemoveUserItems().at(0).trigger('click');
expect(findErrorModal().html()).toBe('');
});
it('renders the error modal if the user is removable', async () => {
await findAllRemoveUserItems().at(2).trigger('click');
expect(findErrorModal().text()).toContain(CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_CONTENT);
});
});
}); });
describe('pagination', () => { describe('pagination', () => {
......
...@@ -5129,6 +5129,9 @@ msgstr "" ...@@ -5129,6 +5129,9 @@ msgstr ""
msgid "Billing|An error occurred while removing a billable member" msgid "Billing|An error occurred while removing a billable member"
msgstr "" msgstr ""
msgid "Billing|Cannot remove user"
msgstr ""
msgid "Billing|Direct memberships" msgid "Billing|Direct memberships"
msgstr "" msgstr ""
...@@ -5141,6 +5144,9 @@ msgstr "" ...@@ -5141,6 +5144,9 @@ msgstr ""
msgid "Billing|Group invite" msgid "Billing|Group invite"
msgstr "" msgstr ""
msgid "Billing|Members who were invited via a group invitation cannot be removed. You can either remove the entire group, or ask an Owner of the invited group to remove the member."
msgstr ""
msgid "Billing|No users to display." msgid "Billing|No users to display."
msgstr "" msgstr ""
...@@ -22744,6 +22750,9 @@ msgstr "" ...@@ -22744,6 +22750,9 @@ msgstr ""
msgid "Ok, let's go" msgid "Ok, let's go"
msgstr "" msgstr ""
msgid "Okay"
msgstr ""
msgid "Oldest first" msgid "Oldest first"
msgstr "" msgstr ""
......
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