Commit 1365216f authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch 'vs-add-remove-button-to-billable-members' into 'master'

Add remove button to billable members

See merge request gitlab-org/gitlab!54458
parents 41261935 338696d6
...@@ -3,6 +3,7 @@ import { buildApiUrl } from './api_utils'; ...@@ -3,6 +3,7 @@ import { buildApiUrl } from './api_utils';
import { DEFAULT_PER_PAGE } from './constants'; import { DEFAULT_PER_PAGE } from './constants';
const GROUPS_PATH = '/api/:version/groups.json'; const GROUPS_PATH = '/api/:version/groups.json';
const GROUPS_MEMBERS_SINGLE_PATH = '/api/:version/groups/:group_id/members/:id';
export function getGroups(query, options, callback = () => {}) { export function getGroups(query, options, callback = () => {}) {
const url = buildApiUrl(GROUPS_PATH); const url = buildApiUrl(GROUPS_PATH);
...@@ -20,3 +21,11 @@ export function getGroups(query, options, callback = () => {}) { ...@@ -20,3 +21,11 @@ export function getGroups(query, options, callback = () => {}) {
return data; return data;
}); });
} }
export function removeMemberFromGroup(groupId, memberId, options) {
const url = buildApiUrl(GROUPS_MEMBERS_SINGLE_PATH)
.replace(':group_id', groupId)
.replace(':id', memberId);
return axios.delete(url, { params: { ...options } });
}
<script>
import {
GlBadge,
GlFormInput,
GlModal,
GlSprintf,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import {
REMOVE_MEMBER_MODAL_ID,
REMOVE_MEMBER_MODAL_CONTENT_TEXT_TEMPLATE,
} from 'ee/billings/seat_usage/constants';
import csrf from '~/lib/utils/csrf';
import { __, s__, sprintf } from '~/locale';
export default {
name: 'RemoveMemberModal',
csrf,
components: {
GlFormInput,
GlModal,
GlSprintf,
GlBadge,
},
directives: {
SafeHtml,
},
data() {
return {
enteredMemberUsername: null,
};
},
computed: {
...mapState(['namespaceName', 'namespaceId', 'memberToRemove']),
modalTitle() {
return sprintf(s__('Billing|Remove user %{username} from your subscription'), {
username: this.usernameWithAtPrepended,
});
},
canSubmit() {
return this.enteredMemberUsername === this.memberToRemove.username;
},
modalText() {
return REMOVE_MEMBER_MODAL_CONTENT_TEXT_TEMPLATE;
},
actionPrimaryProps() {
return {
text: __('Remove user'),
attributes: {
variant: 'danger',
disabled: !this.canSubmit,
class: 'gl-xs-w-full',
},
};
},
actionCancelProps() {
return {
text: __('Cancel'),
attributes: {
class: 'gl-xs-w-full',
},
};
},
usernameWithAtPrepended() {
return `@${this.memberToRemove.username}`;
},
},
methods: {
...mapActions(['removeMember', 'setMemberToRemove']),
},
modalId: REMOVE_MEMBER_MODAL_ID,
i18n: {
inputLabel: s__('Billing|Type %{username} to confirm'),
},
};
</script>
<template>
<gl-modal
v-if="memberToRemove"
v-bind="$attrs"
:modal-id="$options.modalId"
:action-primary="actionPrimaryProps"
:action-cancel="actionCancelProps"
:title="modalTitle"
data-qa-selector="remove_member_modal"
:ok-disabled="!canSubmit"
@primary="removeMember"
@canceled="setMemberToRemove(null)"
>
<p>
<gl-sprintf :message="modalText">
<template #username>
<strong>{{ usernameWithAtPrepended }}</strong>
</template>
<template #namespace>{{ namespaceName }}</template>
</gl-sprintf>
</p>
<label id="input-label">
<gl-sprintf :message="this.$options.i18n.inputLabel">
<template #username>
<gl-badge variant="danger">{{ memberToRemove.username }}</gl-badge>
</template>
</gl-sprintf>
</label>
<gl-form-input v-model.trim="enteredMemberUsername" aria-labelledby="input-label" />
</gl-modal>
</template>
<script> <script>
import { import {
GlTable,
GlAvatarLabeled, GlAvatarLabeled,
GlAvatarLink, GlAvatarLink,
GlBadge,
GlDropdown,
GlDropdownItem,
GlModalDirective,
GlPagination, GlPagination,
GlTooltipDirective,
GlSearchBoxByType, GlSearchBoxByType,
GlBadge, GlTable,
GlTooltipDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { parseInt, debounce } from 'lodash'; import { parseInt, debounce } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import {
FIELDS,
AVATAR_SIZE,
SEARCH_DEBOUNCE_MS,
REMOVE_MEMBER_MODAL_ID,
} from 'ee/billings/seat_usage/constants';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import RemoveMemberModal from './remove_member_modal.vue';
const AVATAR_SIZE = 32;
const SEARCH_DEBOUNCE_MS = 250;
export default { export default {
directives: { directives: {
GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
components: { components: {
GlTable,
GlAvatarLabeled, GlAvatarLabeled,
GlAvatarLink, GlAvatarLink,
GlBadge,
GlDropdown,
GlDropdownItem,
GlPagination, GlPagination,
GlSearchBoxByType, GlSearchBoxByType,
GlBadge, GlTable,
RemoveMemberModal,
}, },
data() { data() {
return { return {
fields: ['user', 'email'],
searchQuery: '', searchQuery: '',
}; };
}, },
computed: { computed: {
...mapState(['isLoading', 'page', 'perPage', 'total', 'namespaceName']), ...mapState([
'isLoading',
'page',
'perPage',
'total',
'namespaceName',
'namespaceId',
'memberToRemove',
]),
...mapGetters(['tableItems']), ...mapGetters(['tableItems']),
currentPage: { currentPage: {
get() { get() {
...@@ -75,7 +93,7 @@ export default { ...@@ -75,7 +93,7 @@ export default {
this.fetchBillableMembersList(); this.fetchBillableMembersList();
}, },
methods: { methods: {
...mapActions(['fetchBillableMembersList', 'resetMembers']), ...mapActions(['fetchBillableMembersList', 'resetMembers', 'setMemberToRemove']),
onSearchEnter() { onSearchEnter() {
this.debouncedSearch.cancel(); this.debouncedSearch.cancel();
this.executeQuery(); this.executeQuery();
...@@ -91,10 +109,14 @@ export default { ...@@ -91,10 +109,14 @@ export default {
} }
}, },
}, },
avatarSize: AVATAR_SIZE, i18n: {
emailNotVisibleTooltipText: s__( emailNotVisibleTooltipText: s__(
'Billing|An email address is only visible for users with public emails.', 'Billing|An email address is only visible for users with public emails.',
), ),
},
avatarSize: AVATAR_SIZE,
fields: FIELDS,
removeMemberModalId: REMOVE_MEMBER_MODAL_ID,
}; };
</script> </script>
...@@ -124,7 +146,7 @@ export default { ...@@ -124,7 +146,7 @@ export default {
<gl-table <gl-table
class="seats-table" class="seats-table"
:items="tableItems" :items="tableItems"
:fields="fields" :fields="$options.fields"
:busy="isLoading" :busy="isLoading"
:show-empty="true" :show-empty="true"
data-testid="table" data-testid="table"
...@@ -150,12 +172,24 @@ export default { ...@@ -150,12 +172,24 @@ export default {
<span <span
v-else v-else
v-gl-tooltip v-gl-tooltip
:title="$options.emailNotVisibleTooltipText" :title="$options.i18n.emailNotVisibleTooltipText"
class="gl-font-style-italic" class="gl-font-style-italic"
>{{ s__('Billing|Private') }}</span
> >
{{ s__('Billing|Private') }}
</span>
</div> </div>
</template> </template>
<template #cell(actions)="data">
<gl-dropdown icon="ellipsis_h" right data-testid="user-actions">
<gl-dropdown-item
v-gl-modal="$options.removeMemberModalId"
@click="setMemberToRemove(data.item.user)"
>
{{ __('Remove user') }}
</gl-dropdown-item>
</gl-dropdown>
</template>
</gl-table> </gl-table>
<gl-pagination <gl-pagination
...@@ -166,5 +200,7 @@ export default { ...@@ -166,5 +200,7 @@ export default {
align="center" align="center"
class="gl-mt-5" class="gl-mt-5"
/> />
<remove-member-modal v-if="memberToRemove" :modal-id="$options.removeMemberModalId" />
</section> </section>
</template> </template>
import { __, s__ } from '~/locale';
export const DEFAULT_TH_CLASSES =
'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!';
const thWidthClass = (width) => `gl-w-${width}p ${DEFAULT_TH_CLASSES}`;
export const FIELDS = [
{
key: 'user',
label: __('User'),
thClass: thWidthClass(40),
},
{
key: 'email',
label: __('Email'),
thClass: thWidthClass(40),
},
{
key: 'actions',
label: '',
tdClass: 'text-right',
customStyle: { width: '35px' },
},
];
export const REMOVE_MEMBER_MODAL_ID = 'member-remove-modal';
export const REMOVE_MEMBER_MODAL_CONTENT_TEXT_TEMPLATE = s__(
`Billing|You are about to remove user %{username} from your subscription.
If you continue, the user will be removed from the %{namespace}
group and all its subgroups and projects. This action can't be undone.`,
);
export const AVATAR_SIZE = 32;
export const SEARCH_DEBOUNCE_MS = 250;
import Api from 'ee/api'; import Api from 'ee/api';
import createFlash from '~/flash'; import * as GroupsApi from '~/api/groups_api';
import createFlash, { FLASH_TYPES } from '~/flash';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import * as types from './mutation_types'; import * as types from './mutation_types';
...@@ -26,3 +27,31 @@ export const receiveBillableMembersListError = ({ commit }) => { ...@@ -26,3 +27,31 @@ export const receiveBillableMembersListError = ({ commit }) => {
export const resetMembers = ({ commit }) => { export const resetMembers = ({ commit }) => {
commit(types.RESET_MEMBERS); commit(types.RESET_MEMBERS);
}; };
export const setMemberToRemove = ({ commit }, member) => {
commit(types.SET_MEMBER_TO_REMOVE, member);
};
export const removeMember = ({ dispatch, state }) => {
return GroupsApi.removeMemberFromGroup(state.namespaceId, state.memberToRemove.id)
.then(() => dispatch('removeMemberSuccess'))
.catch(() => dispatch('removeMemberError'));
};
export const removeMemberSuccess = ({ dispatch, commit }) => {
dispatch('fetchBillableMembersList');
createFlash({
message: s__('Billing|User was successfully removed'),
type: FLASH_TYPES.SUCCESS,
});
commit(types.REMOVE_MEMBER_SUCCESS);
};
export const removeMemberError = ({ commit }) => {
createFlash({
message: s__('Billing|An error occurred while removing a billable member'),
});
commit(types.REMOVE_MEMBER_ERROR);
};
export const tableItems = (state) => { export const tableItems = (state) => {
if (state.members.length) { if (state.members.length) {
return state.members.map(({ name, username, avatar_url, web_url, email }) => { return state.members.map(({ id, name, username, avatar_url, web_url, email }) => {
const formattedUserName = `@${username}`; const formattedUserName = `@${username}`;
return { user: { name, username: formattedUserName, avatar_url, web_url }, email }; return { user: { id, name, username: formattedUserName, avatar_url, web_url }, email };
}); });
} }
return []; return [];
......
...@@ -5,3 +5,7 @@ export const RECEIVE_BILLABLE_MEMBERS_ERROR = 'RECEIVE_BILLABLE_MEMBERS_ERROR'; ...@@ -5,3 +5,7 @@ export const RECEIVE_BILLABLE_MEMBERS_ERROR = 'RECEIVE_BILLABLE_MEMBERS_ERROR';
export const SET_SEARCH = 'SET_SEARCH'; export const SET_SEARCH = 'SET_SEARCH';
export const RESET_MEMBERS = 'RESET_MEMBERS'; export const RESET_MEMBERS = 'RESET_MEMBERS';
export const REMOVE_MEMBER = 'REMOVE_MEMBER';
export const REMOVE_MEMBER_SUCCESS = 'REMOVE_MEMBER_SUCCESS';
export const REMOVE_MEMBER_ERROR = 'REMOVE_MEMBER_ERROR';
export const SET_MEMBER_TO_REMOVE = 'SET_MEMBER_TO_REMOVE';
...@@ -40,4 +40,29 @@ export default { ...@@ -40,4 +40,29 @@ export default {
state.isLoading = false; state.isLoading = false;
}, },
[types.SET_MEMBER_TO_REMOVE](state, memberToRemove) {
if (!memberToRemove) {
state.memberToRemove = null;
} else {
state.memberToRemove = state.members.find((member) => member.id === memberToRemove.id);
}
},
[types.REMOVE_MEMBER](state) {
state.isLoading = true;
state.hasError = false;
},
[types.REMOVE_MEMBER_SUCCESS](state) {
state.isLoading = false;
state.hasError = false;
state.memberToRemove = null;
},
[types.REMOVE_MEMBER_ERROR](state) {
state.isLoading = false;
state.hasError = true;
state.memberToRemove = null;
},
}; };
...@@ -7,4 +7,5 @@ export default ({ namespaceId = null, namespaceName = null } = {}) => ({ ...@@ -7,4 +7,5 @@ export default ({ namespaceId = null, namespaceName = null } = {}) => ({
total: null, total: null,
page: null, page: null,
perPage: null, perPage: null,
memberToRemove: null,
}); });
---
title: Add remove button to billable members
merge_request: 54458
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Groups > Billing > Seat Usage', :js do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:sub_group) { create(:group, parent: group) }
let_it_be(:maintainer) { create(:user) }
let_it_be(:user_from_sub_group) { create(:user) }
before do
allow(Gitlab).to receive(:com?).and_return(true)
stub_application_setting(check_namespace_plan: true)
group.add_owner(user)
group.add_maintainer(maintainer)
sub_group.add_maintainer(user_from_sub_group)
sign_in(user)
visit group_seat_usage_path(group)
wait_for_requests
end
context 'seat usage table' do
it 'displays correct number of users' do
within '[data-testid="table"]' do
expect(all('tr').count).to eq(3)
end
end
end
context 'when removing user' do
let(:user_to_remove_row) do
within '[data-testid="table"]' do
find('tr', text: maintainer.name)
end
end
context 'with a modal to confirm removal' do
before do
within user_to_remove_row do
find('[data-testid="user-actions"]').click
click_button 'Remove user'
end
end
it 'has disabled the remove button' do
within '[data-qa-selector="remove_member_modal"]' do
expect(page).to have_button('Remove user', disabled: true)
end
end
it 'enables the remove button when user enters valid username' do
within '[data-qa-selector="remove_member_modal"]' do
find('input').fill_in(with: maintainer.username)
find('input').send_keys(:tab)
expect(page).to have_button('Remove user', disabled: false)
end
end
it 'does not enable button when user enters invalid username' do
within '[data-qa-selector="remove_member_modal"]' do
find('input').fill_in(with: 'invalid username')
find('input').send_keys(:tab)
expect(page).to have_button('Remove user', disabled: true)
end
end
end
context 'removing the user' do
before do
within user_to_remove_row do
find('[data-testid="user-actions"]').click
click_button 'Remove user'
end
end
it 'shows a flash message' do
within '[data-qa-selector="remove_member_modal"]' do
find('input').fill_in(with: maintainer.username)
find('input').send_keys(:tab)
click_button('Remove user')
end
wait_for_all_requests
within '[data-testid="table"]' do
expect(all('tr').count).to eq(2)
end
expect(page.find('.flash-container')).to have_content('User was successfully removed')
end
context 'removing the user from a sub-group' do
it 'updates the seat table of the parent group' do
within '[data-testid="table"]' do
expect(all('tr').count).to eq(3)
end
visit group_group_members_path(sub_group)
click_button('Remove member')
within '[data-qa-selector="remove_member_modal_content"]' do
click_button('Remove member')
end
wait_for_all_requests
visit group_seat_usage_path(group)
wait_for_all_requests
within '[data-testid="table"]' do
expect(all('tr').count).to eq(2)
end
end
end
end
end
end
...@@ -68,6 +68,7 @@ export const mockDataSubscription = { ...@@ -68,6 +68,7 @@ export const mockDataSubscription = {
export const mockDataSeats = { export const mockDataSeats = {
data: [ data: [
{ {
id: 2,
name: 'Administrator', name: 'Administrator',
username: 'root', username: 'root',
avatar_url: 'path/to/img_administrator', avatar_url: 'path/to/img_administrator',
...@@ -75,6 +76,7 @@ export const mockDataSeats = { ...@@ -75,6 +76,7 @@ export const mockDataSeats = {
email: 'administrator@email.com', email: 'administrator@email.com',
}, },
{ {
id: 3,
name: 'Agustin Walker', name: 'Agustin Walker',
username: 'lester.orn', username: 'lester.orn',
avatar_url: 'path/to/img_agustin_walker', avatar_url: 'path/to/img_agustin_walker',
...@@ -82,6 +84,7 @@ export const mockDataSeats = { ...@@ -82,6 +84,7 @@ export const mockDataSeats = {
email: 'agustin_walker@email.com', email: 'agustin_walker@email.com',
}, },
{ {
id: 4,
name: 'Joella Miller', name: 'Joella Miller',
username: 'era', username: 'era',
avatar_url: 'path/to/img_joella_miller', avatar_url: 'path/to/img_joella_miller',
...@@ -100,6 +103,7 @@ export const mockTableItems = [ ...@@ -100,6 +103,7 @@ export const mockTableItems = [
{ {
email: 'administrator@email.com', email: 'administrator@email.com',
user: { user: {
id: 2,
avatar_url: 'path/to/img_administrator', avatar_url: 'path/to/img_administrator',
name: 'Administrator', name: 'Administrator',
username: '@root', username: '@root',
...@@ -109,6 +113,7 @@ export const mockTableItems = [ ...@@ -109,6 +113,7 @@ export const mockTableItems = [
{ {
email: 'agustin_walker@email.com', email: 'agustin_walker@email.com',
user: { user: {
id: 3,
avatar_url: 'path/to/img_agustin_walker', avatar_url: 'path/to/img_agustin_walker',
name: 'Agustin Walker', name: 'Agustin Walker',
username: '@lester.orn', username: '@lester.orn',
...@@ -118,6 +123,7 @@ export const mockTableItems = [ ...@@ -118,6 +123,7 @@ export const mockTableItems = [
{ {
email: null, email: null,
user: { user: {
id: 4,
avatar_url: 'path/to/img_joella_miller', avatar_url: 'path/to/img_joella_miller',
name: 'Joella Miller', name: 'Joella Miller',
username: '@era', username: '@era',
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
exports[`Subscription Seats renders table content renders the correct data 1`] = ` exports[`Subscription Seats renders table content renders the correct data 1`] = `
Array [ Array [
Object { Object {
"dropdownExists": true,
"email": "administrator@email.com", "email": "administrator@email.com",
"tooltip": undefined, "tooltip": undefined,
"user": Object { "user": Object {
...@@ -18,6 +19,7 @@ Array [ ...@@ -18,6 +19,7 @@ Array [
}, },
}, },
Object { Object {
"dropdownExists": true,
"email": "agustin_walker@email.com", "email": "agustin_walker@email.com",
"tooltip": undefined, "tooltip": undefined,
"user": Object { "user": Object {
...@@ -33,6 +35,7 @@ Array [ ...@@ -33,6 +35,7 @@ Array [
}, },
}, },
Object { Object {
"dropdownExists": true,
"email": "Private", "email": "Private",
"tooltip": "An email address is only visible for users with public emails.", "tooltip": "An email address is only visible for users with public emails.",
"user": Object { "user": Object {
......
import { GlSprintf } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import Vuex from 'vuex';
import RemoveMemberModal from 'ee/billings/seat_usage/components/remove_member_modal.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('RemoveMemberModal', () => {
let wrapper;
const defaultState = {
namespaceName: 'foo',
namespaceId: '1',
memberToRemove: {
id: 2,
username: 'username',
name: 'First Last',
},
};
const createStore = () => {
return new Vuex.Store({
state: defaultState,
});
};
const createComponent = (mountFn = shallowMount) => {
wrapper = mountFn(RemoveMemberModal, {
store: createStore(),
stubs: {
GlSprintf,
},
localVue,
});
};
beforeEach(() => {
createComponent();
return nextTick();
});
afterEach(() => {
wrapper.destroy();
});
describe('on rendering', () => {
it('renders the submit button disabled', () => {
expect(wrapper.attributes('ok-disabled')).toBe('true');
});
it('renders the title with username', () => {
expect(wrapper.attributes('title')).toBe(
`Remove user @${defaultState.memberToRemove.username} from your subscription`,
);
});
it('renders the confirmation label with username', () => {
expect(wrapper.find('label').text()).toContain(
defaultState.memberToRemove.username.substring(1),
);
});
});
});
import { import {
GlPagination, GlPagination,
GlDropdown,
GlTable, GlTable,
GlAvatarLink, GlAvatarLink,
GlAvatarLabeled, GlAvatarLabeled,
...@@ -92,6 +93,7 @@ describe('Subscription Seats', () => { ...@@ -92,6 +93,7 @@ describe('Subscription Seats', () => {
user: serializeUser(rowWrapper), user: serializeUser(rowWrapper),
email: emailWrapper.text(), email: emailWrapper.text(),
tooltip: emailWrapper.find('span').attributes('title'), tooltip: emailWrapper.find('span').attributes('title'),
dropdownExists: rowWrapper.find(GlDropdown).exists(),
}; };
}; };
...@@ -106,7 +108,6 @@ describe('Subscription Seats', () => { ...@@ -106,7 +108,6 @@ describe('Subscription Seats', () => {
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
it('correct actions are called on create', () => { it('correct actions are called on create', () => {
...@@ -126,7 +127,6 @@ describe('Subscription Seats', () => { ...@@ -126,7 +127,6 @@ describe('Subscription Seats', () => {
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
describe('heading text', () => { describe('heading text', () => {
...@@ -145,9 +145,12 @@ describe('Subscription Seats', () => { ...@@ -145,9 +145,12 @@ describe('Subscription Seats', () => {
}); });
it('pagination is rendered and passed correct values', () => { it('pagination is rendered and passed correct values', () => {
expect(findPagination().vm.value).toBe(1); const pagination = findPagination();
expect(findPagination().props('perPage')).toBe(5);
expect(findPagination().props('totalItems')).toBe(300); expect(pagination.props()).toMatchObject({
perPage: 5,
totalItems: 300,
});
}); });
}); });
...@@ -163,7 +166,6 @@ describe('Subscription Seats', () => { ...@@ -163,7 +166,6 @@ describe('Subscription Seats', () => {
expect(findPagination().exists()).toBe(false); expect(findPagination().exists()).toBe(false);
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}, },
); );
}); });
...@@ -175,7 +177,6 @@ describe('Subscription Seats', () => { ...@@ -175,7 +177,6 @@ describe('Subscription Seats', () => {
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
it('displays table in loading state', () => { it('displays table in loading state', () => {
......
...@@ -5,8 +5,10 @@ import * as types from 'ee/billings/seat_usage/store/mutation_types'; ...@@ -5,8 +5,10 @@ import * as types from 'ee/billings/seat_usage/store/mutation_types';
import State from 'ee/billings/seat_usage/store/state'; import State from 'ee/billings/seat_usage/store/state';
import { mockDataSeats } from 'ee_jest/billings/mock_data'; import { mockDataSeats } from 'ee_jest/billings/mock_data';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import createFlash from '~/flash'; import * as GroupsApi from '~/api/groups_api';
import createFlash, { FLASH_TYPES } from '~/flash';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status';
jest.mock('~/flash'); jest.mock('~/flash');
...@@ -20,8 +22,7 @@ describe('seats actions', () => { ...@@ -20,8 +22,7 @@ describe('seats actions', () => {
}); });
afterEach(() => { afterEach(() => {
mock.restore(); mock.reset();
createFlash.mockClear();
}); });
describe('fetchBillableMembersList', () => { describe('fetchBillableMembersList', () => {
...@@ -49,7 +50,7 @@ describe('seats actions', () => { ...@@ -49,7 +50,7 @@ describe('seats actions', () => {
beforeEach(() => { beforeEach(() => {
mock mock
.onGet('/api/v4/groups/1/billable_members') .onGet('/api/v4/groups/1/billable_members')
.replyOnce(200, mockDataSeats.data, mockDataSeats.headers); .replyOnce(httpStatusCodes.OK, mockDataSeats.data, mockDataSeats.headers);
}); });
it('should dispatch the request and success actions', () => { it('should dispatch the request and success actions', () => {
...@@ -69,7 +70,7 @@ describe('seats actions', () => { ...@@ -69,7 +70,7 @@ describe('seats actions', () => {
describe('on error', () => { describe('on error', () => {
beforeEach(() => { beforeEach(() => {
mock.onGet('/api/v4/groups/1/billable_members').replyOnce(404, {}); mock.onGet('/api/v4/groups/1/billable_members').replyOnce(httpStatusCodes.NOT_FOUND, {});
}); });
it('should dispatch the request and error actions', () => { it('should dispatch the request and error actions', () => {
...@@ -129,4 +130,91 @@ describe('seats actions', () => { ...@@ -129,4 +130,91 @@ describe('seats actions', () => {
}); });
}); });
}); });
describe('setMemberToRemove', () => {
it('should commit the set member mutation', async () => {
await testAction({
action: actions.setMemberToRemove,
state,
expectedMutations: [{ type: types.SET_MEMBER_TO_REMOVE }],
});
});
});
describe('removeMember', () => {
let groupsApiSpy;
beforeEach(() => {
groupsApiSpy = jest.spyOn(GroupsApi, 'removeMemberFromGroup');
state = {
namespaceId: 1,
memberToRemove: {
id: 2,
},
};
});
describe('on success', () => {
beforeEach(() => {
mock.onDelete('/api/v4/groups/1/members/2').reply(httpStatusCodes.OK);
});
it('dispatches the removeMemberSuccess action', async () => {
await testAction({
action: actions.removeMember,
state,
expectedActions: [{ type: 'removeMemberSuccess' }],
});
expect(groupsApiSpy).toHaveBeenCalled();
});
});
describe('on error', () => {
beforeEach(() => {
mock.onDelete('/api/v4/groups/1/members/2').reply(httpStatusCodes.UNPROCESSABLE_ENTITY);
});
it('dispatches the removeMemberError action', async () => {
await testAction({
action: actions.removeMember,
state,
expectedActions: [{ type: 'removeMemberError' }],
});
expect(groupsApiSpy).toHaveBeenCalled();
});
});
});
describe('removeMemberSuccess', () => {
it('dispatches fetchBillableMembersList', async () => {
await testAction({
action: actions.removeMemberSuccess,
state,
expectedActions: [{ type: 'fetchBillableMembersList' }],
expectedMutations: [{ type: types.REMOVE_MEMBER_SUCCESS }],
});
expect(createFlash).toHaveBeenCalledWith({
message: 'User was successfully removed',
type: FLASH_TYPES.SUCCESS,
});
});
});
describe('removeMemberError', () => {
it('commits remove member error', async () => {
await testAction({
action: actions.removeMemberError,
state,
expectedMutations: [{ type: types.REMOVE_MEMBER_ERROR }],
});
expect(createFlash).toHaveBeenCalledWith({
message: 'An error occurred while removing a billable member',
});
});
});
}); });
...@@ -88,4 +88,52 @@ describe('EE billings seats module mutations', () => { ...@@ -88,4 +88,52 @@ describe('EE billings seats module mutations', () => {
expect(state.isLoading).toBeFalsy(); expect(state.isLoading).toBeFalsy();
}); });
}); });
describe('member removal', () => {
const memberToRemove = mockDataSeats.data[0];
beforeEach(() => {
mutations[types.RECEIVE_BILLABLE_MEMBERS_SUCCESS](state, mockDataSeats);
});
describe(types.SET_MEMBER_TO_REMOVE, () => {
it('sets the member to remove', () => {
mutations[types.SET_MEMBER_TO_REMOVE](state, memberToRemove);
expect(state.memberToRemove).toMatchObject(memberToRemove);
});
});
describe(types.REMOVE_MEMBER, () => {
it('sets state to loading', () => {
mutations[types.REMOVE_MEMBER](state, memberToRemove);
expect(state).toMatchObject({ isLoading: true, hasError: false });
});
});
describe(types.REMOVE_MEMBER_SUCCESS, () => {
it('sets state to successfull', () => {
mutations[types.REMOVE_MEMBER_SUCCESS](state, memberToRemove);
expect(state).toMatchObject({
isLoading: false,
hasError: false,
memberToRemove: null,
});
});
});
describe(types.REMOVE_MEMBER_ERROR, () => {
it('sets state to errored', () => {
mutations[types.REMOVE_MEMBER_ERROR](state, memberToRemove);
expect(state).toMatchObject({
isLoading: false,
hasError: true,
memberToRemove: null,
});
});
});
});
}); });
...@@ -4739,6 +4739,9 @@ msgstr "" ...@@ -4739,6 +4739,9 @@ msgstr ""
msgid "Billing|An error occurred while loading billable members list" msgid "Billing|An error occurred while loading billable members list"
msgstr "" msgstr ""
msgid "Billing|An error occurred while removing a billable member"
msgstr ""
msgid "Billing|Enter at least three characters to search." msgid "Billing|Enter at least three characters to search."
msgstr "" msgstr ""
...@@ -4751,12 +4754,24 @@ msgstr "" ...@@ -4751,12 +4754,24 @@ msgstr ""
msgid "Billing|Private" msgid "Billing|Private"
msgstr "" msgstr ""
msgid "Billing|Remove user %{username} from your subscription"
msgstr ""
msgid "Billing|Type %{username} to confirm"
msgstr ""
msgid "Billing|Type to search" msgid "Billing|Type to search"
msgstr "" msgstr ""
msgid "Billing|User was successfully removed"
msgstr ""
msgid "Billing|Users occupying seats in" msgid "Billing|Users occupying seats in"
msgstr "" msgstr ""
msgid "Billing|You are about to remove user %{username} from your subscription. If you continue, the user will be removed from the %{namespace} group and all its subgroups and projects. This action can't be undone."
msgstr ""
msgid "Bitbucket Server Import" msgid "Bitbucket Server Import"
msgstr "" msgstr ""
...@@ -24911,6 +24926,9 @@ msgstr "" ...@@ -24911,6 +24926,9 @@ msgstr ""
msgid "Remove time estimate" msgid "Remove time estimate"
msgstr "" msgstr ""
msgid "Remove user"
msgstr ""
msgid "Remove user & report" msgid "Remove user & report"
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