Commit 338696d6 authored by Vitaly Slobodin's avatar Vitaly Slobodin Committed by Olena Horal-Koretska

Add new cmp for removing billable group member

Add new cmp to the SubscriptionSeats cmp
parent 82736c92
......@@ -3,6 +3,7 @@ import { buildApiUrl } from './api_utils';
import { DEFAULT_PER_PAGE } from './constants';
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 = () => {}) {
const url = buildApiUrl(GROUPS_PATH);
......@@ -20,3 +21,11 @@ export function getGroups(query, options, callback = () => {}) {
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>
import {
GlTable,
GlAvatarLabeled,
GlAvatarLink,
GlBadge,
GlDropdown,
GlDropdownItem,
GlModalDirective,
GlPagination,
GlTooltipDirective,
GlSearchBoxByType,
GlBadge,
GlTable,
GlTooltipDirective,
} from '@gitlab/ui';
import { parseInt, debounce } from 'lodash';
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';
const AVATAR_SIZE = 32;
const SEARCH_DEBOUNCE_MS = 250;
import RemoveMemberModal from './remove_member_modal.vue';
export default {
directives: {
GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective,
},
components: {
GlTable,
GlAvatarLabeled,
GlAvatarLink,
GlBadge,
GlDropdown,
GlDropdownItem,
GlPagination,
GlSearchBoxByType,
GlBadge,
GlTable,
RemoveMemberModal,
},
data() {
return {
fields: ['user', 'email'],
searchQuery: '',
};
},
computed: {
...mapState(['isLoading', 'page', 'perPage', 'total', 'namespaceName']),
...mapState([
'isLoading',
'page',
'perPage',
'total',
'namespaceName',
'namespaceId',
'memberToRemove',
]),
...mapGetters(['tableItems']),
currentPage: {
get() {
......@@ -75,7 +93,7 @@ export default {
this.fetchBillableMembersList();
},
methods: {
...mapActions(['fetchBillableMembersList', 'resetMembers']),
...mapActions(['fetchBillableMembersList', 'resetMembers', 'setMemberToRemove']),
onSearchEnter() {
this.debouncedSearch.cancel();
this.executeQuery();
......@@ -91,10 +109,14 @@ export default {
}
},
},
i18n: {
emailNotVisibleTooltipText: s__(
'Billing|An email address is only visible for users with public emails.',
),
},
avatarSize: AVATAR_SIZE,
emailNotVisibleTooltipText: s__(
'Billing|An email address is only visible for users with public emails.',
),
fields: FIELDS,
removeMemberModalId: REMOVE_MEMBER_MODAL_ID,
};
</script>
......@@ -124,7 +146,7 @@ export default {
<gl-table
class="seats-table"
:items="tableItems"
:fields="fields"
:fields="$options.fields"
:busy="isLoading"
:show-empty="true"
data-testid="table"
......@@ -150,12 +172,24 @@ export default {
<span
v-else
v-gl-tooltip
:title="$options.emailNotVisibleTooltipText"
:title="$options.i18n.emailNotVisibleTooltipText"
class="gl-font-style-italic"
>{{ s__('Billing|Private') }}</span
>
{{ s__('Billing|Private') }}
</span>
</div>
</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-pagination
......@@ -166,5 +200,7 @@ export default {
align="center"
class="gl-mt-5"
/>
<remove-member-modal v-if="memberToRemove" :modal-id="$options.removeMemberModalId" />
</section>
</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 createFlash from '~/flash';
import * as GroupsApi from '~/api/groups_api';
import createFlash, { FLASH_TYPES } from '~/flash';
import { s__ } from '~/locale';
import * as types from './mutation_types';
......@@ -26,3 +27,31 @@ export const receiveBillableMembersListError = ({ commit }) => {
export const resetMembers = ({ commit }) => {
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) => {
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}`;
return { user: { name, username: formattedUserName, avatar_url, web_url }, email };
return { user: { id, name, username: formattedUserName, avatar_url, web_url }, email };
});
}
return [];
......
......@@ -5,3 +5,7 @@ export const RECEIVE_BILLABLE_MEMBERS_ERROR = 'RECEIVE_BILLABLE_MEMBERS_ERROR';
export const SET_SEARCH = 'SET_SEARCH';
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 {
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 } = {}) => ({
total: null,
page: 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 = {
export const mockDataSeats = {
data: [
{
id: 2,
name: 'Administrator',
username: 'root',
avatar_url: 'path/to/img_administrator',
......@@ -75,6 +76,7 @@ export const mockDataSeats = {
email: 'administrator@email.com',
},
{
id: 3,
name: 'Agustin Walker',
username: 'lester.orn',
avatar_url: 'path/to/img_agustin_walker',
......@@ -82,6 +84,7 @@ export const mockDataSeats = {
email: 'agustin_walker@email.com',
},
{
id: 4,
name: 'Joella Miller',
username: 'era',
avatar_url: 'path/to/img_joella_miller',
......@@ -100,6 +103,7 @@ export const mockTableItems = [
{
email: 'administrator@email.com',
user: {
id: 2,
avatar_url: 'path/to/img_administrator',
name: 'Administrator',
username: '@root',
......@@ -109,6 +113,7 @@ export const mockTableItems = [
{
email: 'agustin_walker@email.com',
user: {
id: 3,
avatar_url: 'path/to/img_agustin_walker',
name: 'Agustin Walker',
username: '@lester.orn',
......@@ -118,6 +123,7 @@ export const mockTableItems = [
{
email: null,
user: {
id: 4,
avatar_url: 'path/to/img_joella_miller',
name: 'Joella Miller',
username: '@era',
......
......@@ -3,6 +3,7 @@
exports[`Subscription Seats renders table content renders the correct data 1`] = `
Array [
Object {
"dropdownExists": true,
"email": "administrator@email.com",
"tooltip": undefined,
"user": Object {
......@@ -18,6 +19,7 @@ Array [
},
},
Object {
"dropdownExists": true,
"email": "agustin_walker@email.com",
"tooltip": undefined,
"user": Object {
......@@ -33,6 +35,7 @@ Array [
},
},
Object {
"dropdownExists": true,
"email": "Private",
"tooltip": "An email address is only visible for users with public emails.",
"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 {
GlPagination,
GlDropdown,
GlTable,
GlAvatarLink,
GlAvatarLabeled,
......@@ -92,6 +93,7 @@ describe('Subscription Seats', () => {
user: serializeUser(rowWrapper),
email: emailWrapper.text(),
tooltip: emailWrapper.find('span').attributes('title'),
dropdownExists: rowWrapper.find(GlDropdown).exists(),
};
};
......@@ -106,7 +108,6 @@ describe('Subscription Seats', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('correct actions are called on create', () => {
......@@ -126,7 +127,6 @@ describe('Subscription Seats', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('heading text', () => {
......@@ -145,9 +145,12 @@ describe('Subscription Seats', () => {
});
it('pagination is rendered and passed correct values', () => {
expect(findPagination().vm.value).toBe(1);
expect(findPagination().props('perPage')).toBe(5);
expect(findPagination().props('totalItems')).toBe(300);
const pagination = findPagination();
expect(pagination.props()).toMatchObject({
perPage: 5,
totalItems: 300,
});
});
});
......@@ -163,7 +166,6 @@ describe('Subscription Seats', () => {
expect(findPagination().exists()).toBe(false);
wrapper.destroy();
wrapper = null;
},
);
});
......@@ -175,7 +177,6 @@ describe('Subscription Seats', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('displays table in loading state', () => {
......
......@@ -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 { mockDataSeats } from 'ee_jest/billings/mock_data';
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 httpStatusCodes from '~/lib/utils/http_status';
jest.mock('~/flash');
......@@ -20,8 +22,7 @@ describe('seats actions', () => {
});
afterEach(() => {
mock.restore();
createFlash.mockClear();
mock.reset();
});
describe('fetchBillableMembersList', () => {
......@@ -49,7 +50,7 @@ describe('seats actions', () => {
beforeEach(() => {
mock
.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', () => {
......@@ -69,7 +70,7 @@ describe('seats actions', () => {
describe('on error', () => {
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', () => {
......@@ -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', () => {
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 ""
msgid "Billing|An error occurred while loading billable members list"
msgstr ""
msgid "Billing|An error occurred while removing a billable member"
msgstr ""
msgid "Billing|Enter at least three characters to search."
msgstr ""
......@@ -4751,12 +4754,24 @@ msgstr ""
msgid "Billing|Private"
msgstr ""
msgid "Billing|Remove user %{username} from your subscription"
msgstr ""
msgid "Billing|Type %{username} to confirm"
msgstr ""
msgid "Billing|Type to search"
msgstr ""
msgid "Billing|User was successfully removed"
msgstr ""
msgid "Billing|Users occupying seats in"
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"
msgstr ""
......@@ -24911,6 +24926,9 @@ msgstr ""
msgid "Remove time estimate"
msgstr ""
msgid "Remove user"
msgstr ""
msgid "Remove user & report"
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