Commit 7f6474e2 authored by Simon Knox's avatar Simon Knox

Merge branch 'vij-remove-billable-member-fe' into 'master'

Change members to billable members in frontend

See merge request gitlab-org/gitlab!55824
parents e899efc5 8526a2b4
......@@ -3,7 +3,6 @@ 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);
......@@ -21,11 +20,3 @@ 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 } });
}
import { buildApiUrl } from '~/api/api_utils';
import axios from '~/lib/utils/axios_utils';
const GROUPS_BILLABLE_MEMBERS_SINGLE_PATH = '/api/:version/groups/:group_id/billable_members/:id';
export function removeBillableMemberFromGroup(groupId, memberId, options) {
const url = buildApiUrl(GROUPS_BILLABLE_MEMBERS_SINGLE_PATH)
.replace(':group_id', groupId)
.replace(':id', memberId);
return axios.delete(url, { params: { ...options } });
}
......@@ -8,14 +8,14 @@ import {
} from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import {
REMOVE_MEMBER_MODAL_ID,
REMOVE_MEMBER_MODAL_CONTENT_TEXT_TEMPLATE,
REMOVE_BILLABLE_MEMBER_MODAL_ID,
REMOVE_BILLABLE_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',
name: 'RemoveBillableMemberModal',
csrf,
components: {
GlFormInput,
......@@ -32,17 +32,17 @@ export default {
};
},
computed: {
...mapState(['namespaceName', 'namespaceId', 'memberToRemove']),
...mapState(['namespaceName', 'namespaceId', 'billableMemberToRemove']),
modalTitle() {
return sprintf(s__('Billing|Remove user %{username} from your subscription'), {
username: this.usernameWithAtPrepended,
});
},
canSubmit() {
return this.enteredMemberUsername === this.memberToRemove.username;
return this.enteredMemberUsername === this.billableMemberToRemove.username;
},
modalText() {
return REMOVE_MEMBER_MODAL_CONTENT_TEXT_TEMPLATE;
return REMOVE_BILLABLE_MEMBER_MODAL_CONTENT_TEXT_TEMPLATE;
},
actionPrimaryProps() {
return {
......@@ -63,13 +63,13 @@ export default {
};
},
usernameWithAtPrepended() {
return `@${this.memberToRemove.username}`;
return `@${this.billableMemberToRemove.username}`;
},
},
methods: {
...mapActions(['removeMember', 'setMemberToRemove']),
...mapActions(['removeBillableMember', 'setBillableMemberToRemove']),
},
modalId: REMOVE_MEMBER_MODAL_ID,
modalId: REMOVE_BILLABLE_MEMBER_MODAL_ID,
i18n: {
inputLabel: s__('Billing|Type %{username} to confirm'),
},
......@@ -78,16 +78,15 @@ export default {
<template>
<gl-modal
v-if="memberToRemove"
v-bind="$attrs"
v-if="billableMemberToRemove"
:modal-id="$options.modalId"
:action-primary="actionPrimaryProps"
:action-cancel="actionCancelProps"
:title="modalTitle"
data-qa-selector="remove_member_modal"
data-qa-selector="remove_billable_member_modal"
:ok-disabled="!canSubmit"
@primary="removeMember"
@canceled="setMemberToRemove(null)"
@primary="removeBillableMember"
@canceled="setBillableMemberToRemove(null)"
>
<p>
<gl-sprintf :message="modalText">
......@@ -101,7 +100,7 @@ export default {
<label id="input-label">
<gl-sprintf :message="this.$options.i18n.inputLabel">
<template #username>
<gl-badge variant="danger">{{ memberToRemove.username }}</gl-badge>
<gl-badge variant="danger">{{ billableMemberToRemove.username }}</gl-badge>
</template>
</gl-sprintf>
</label>
......
......@@ -17,11 +17,11 @@ import {
FIELDS,
AVATAR_SIZE,
SEARCH_DEBOUNCE_MS,
REMOVE_MEMBER_MODAL_ID,
REMOVE_BILLABLE_MEMBER_MODAL_ID,
} from 'ee/billings/seat_usage/constants';
import { s__ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import RemoveMemberModal from './remove_member_modal.vue';
import RemoveBillableMemberModal from './remove_billable_member_modal.vue';
export default {
directives: {
......@@ -37,7 +37,7 @@ export default {
GlPagination,
GlSearchBoxByType,
GlTable,
RemoveMemberModal,
RemoveBillableMemberModal,
TimeAgoTooltip,
},
data() {
......@@ -53,7 +53,7 @@ export default {
'total',
'namespaceName',
'namespaceId',
'memberToRemove',
'billableMemberToRemove',
]),
...mapGetters(['tableItems']),
currentPage: {
......@@ -95,7 +95,11 @@ export default {
this.fetchBillableMembersList();
},
methods: {
...mapActions(['fetchBillableMembersList', 'resetMembers', 'setMemberToRemove']),
...mapActions([
'fetchBillableMembersList',
'resetBillableMembers',
'setBillableMemberToRemove',
]),
onSearchEnter() {
this.debouncedSearch.cancel();
this.executeQuery();
......@@ -107,7 +111,7 @@ export default {
if (queryLength === 0 || queryLength >= MIN_SEARCH_LENGTH) {
this.debouncedSearch();
} else if (queryLength < MIN_SEARCH_LENGTH) {
this.resetMembers();
this.resetBillableMembers();
}
},
},
......@@ -118,7 +122,7 @@ export default {
},
avatarSize: AVATAR_SIZE,
fields: FIELDS,
removeMemberModalId: REMOVE_MEMBER_MODAL_ID,
removeBillableMemberModalId: REMOVE_BILLABLE_MEMBER_MODAL_ID,
};
</script>
......@@ -195,8 +199,8 @@ export default {
<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)"
v-gl-modal="$options.removeBillableMemberModalId"
@click="setBillableMemberToRemove(data.item.user)"
>
{{ __('Remove user') }}
</gl-dropdown-item>
......@@ -213,6 +217,9 @@ export default {
class="gl-mt-5"
/>
<remove-member-modal v-if="memberToRemove" :modal-id="$options.removeMemberModalId" />
<remove-billable-member-modal
v-if="billableMemberToRemove"
:modal-id="$options.removeBillableMemberModalId"
/>
</section>
</template>
......@@ -28,8 +28,8 @@ export const FIELDS = [
},
];
export const REMOVE_MEMBER_MODAL_ID = 'member-remove-modal';
export const REMOVE_MEMBER_MODAL_CONTENT_TEXT_TEMPLATE = s__(
export const REMOVE_BILLABLE_MEMBER_MODAL_ID = 'billable-member-remove-modal';
export const REMOVE_BILLABLE_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.`,
......
import Api from 'ee/api';
import * as GroupsApi from '~/api/groups_api';
import * as GroupsApi from 'ee/api/groups_api';
import createFlash, { FLASH_TYPES } from '~/flash';
import { s__ } from '~/locale';
import * as types from './mutation_types';
......@@ -24,21 +24,21 @@ export const receiveBillableMembersListError = ({ commit }) => {
commit(types.RECEIVE_BILLABLE_MEMBERS_ERROR);
};
export const resetMembers = ({ commit }) => {
commit(types.RESET_MEMBERS);
export const resetBillableMembers = ({ commit }) => {
commit(types.RESET_BILLABLE_MEMBERS);
};
export const setMemberToRemove = ({ commit }, member) => {
commit(types.SET_MEMBER_TO_REMOVE, member);
export const setBillableMemberToRemove = ({ commit }, member) => {
commit(types.SET_BILLABLE_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 removeBillableMember = ({ dispatch, state }) => {
return GroupsApi.removeBillableMemberFromGroup(state.namespaceId, state.billableMemberToRemove.id)
.then(() => dispatch('removeBillableMemberSuccess'))
.catch(() => dispatch('removeBillableMemberError'));
};
export const removeMemberSuccess = ({ dispatch, commit }) => {
export const removeBillableMemberSuccess = ({ dispatch, commit }) => {
dispatch('fetchBillableMembersList');
createFlash({
......@@ -46,12 +46,12 @@ export const removeMemberSuccess = ({ dispatch, commit }) => {
type: FLASH_TYPES.SUCCESS,
});
commit(types.REMOVE_MEMBER_SUCCESS);
commit(types.REMOVE_BILLABLE_MEMBER_SUCCESS);
};
export const removeMemberError = ({ commit }) => {
export const removeBillableMemberError = ({ commit }) => {
createFlash({
message: s__('Billing|An error occurred while removing a billable member'),
});
commit(types.REMOVE_MEMBER_ERROR);
commit(types.REMOVE_BILLABLE_MEMBER_ERROR);
};
......@@ -4,8 +4,8 @@ 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';
export const RESET_BILLABLE_MEMBERS = 'RESET_BILLABLE_MEMBERS';
export const REMOVE_BILLABLE_MEMBER = 'REMOVE_BILLABLE_MEMBER';
export const REMOVE_BILLABLE_MEMBER_SUCCESS = 'REMOVE_BILLABLE_MEMBER_SUCCESS';
export const REMOVE_BILLABLE_MEMBER_ERROR = 'REMOVE_BILLABLE_MEMBER_ERROR';
export const SET_BILLABLE_MEMBER_TO_REMOVE = 'SET_BILLABLE_MEMBER_TO_REMOVE';
......@@ -31,7 +31,7 @@ export default {
state.search = searchString ?? '';
},
[types.RESET_MEMBERS](state) {
[types.RESET_BILLABLE_MEMBERS](state) {
state.members = [];
state.total = null;
......@@ -41,28 +41,30 @@ export default {
state.isLoading = false;
},
[types.SET_MEMBER_TO_REMOVE](state, memberToRemove) {
[types.SET_BILLABLE_MEMBER_TO_REMOVE](state, memberToRemove) {
if (!memberToRemove) {
state.memberToRemove = null;
state.billableMemberToRemove = null;
} else {
state.memberToRemove = state.members.find((member) => member.id === memberToRemove.id);
state.billableMemberToRemove = state.members.find(
(member) => member.id === memberToRemove.id,
);
}
},
[types.REMOVE_MEMBER](state) {
[types.REMOVE_BILLABLE_MEMBER](state) {
state.isLoading = true;
state.hasError = false;
},
[types.REMOVE_MEMBER_SUCCESS](state) {
[types.REMOVE_BILLABLE_MEMBER_SUCCESS](state) {
state.isLoading = false;
state.hasError = false;
state.memberToRemove = null;
state.billableMemberToRemove = null;
},
[types.REMOVE_MEMBER_ERROR](state) {
[types.REMOVE_BILLABLE_MEMBER_ERROR](state) {
state.isLoading = false;
state.hasError = true;
state.memberToRemove = null;
state.billableMemberToRemove = null;
},
};
......@@ -7,5 +7,5 @@ export default ({ namespaceId = null, namespaceName = null } = {}) => ({
total: null,
page: null,
perPage: null,
memberToRemove: null,
billableMemberToRemove: null,
});
export * from './api/groups_api';
......@@ -48,13 +48,13 @@ RSpec.describe 'Groups > Billing > Seat Usage', :js do
end
it 'has disabled the remove button' do
within '[data-qa-selector="remove_member_modal"]' do
within '[data-qa-selector="remove_billable_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
within '[data-qa-selector="remove_billable_member_modal"]' do
find('input').fill_in(with: maintainer.username)
find('input').send_keys(:tab)
......@@ -63,7 +63,7 @@ RSpec.describe 'Groups > Billing > Seat Usage', :js do
end
it 'does not enable button when user enters invalid username' do
within '[data-qa-selector="remove_member_modal"]' do
within '[data-qa-selector="remove_billable_member_modal"]' do
find('input').fill_in(with: 'invalid username')
find('input').send_keys(:tab)
......@@ -81,7 +81,7 @@ RSpec.describe 'Groups > Billing > Seat Usage', :js do
end
it 'shows a flash message' do
within '[data-qa-selector="remove_member_modal"]' do
within '[data-qa-selector="remove_billable_member_modal"]' do
find('input').fill_in(with: maintainer.username)
find('input').send_keys(:tab)
......
......@@ -2,18 +2,18 @@ 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';
import RemoveBillableMemberModal from 'ee/billings/seat_usage/components/remove_billable_member_modal.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('RemoveMemberModal', () => {
describe('RemoveBillableMemberModal', () => {
let wrapper;
const defaultState = {
namespaceName: 'foo',
namespaceId: '1',
memberToRemove: {
billableMemberToRemove: {
id: 2,
username: 'username',
name: 'First Last',
......@@ -27,7 +27,7 @@ describe('RemoveMemberModal', () => {
};
const createComponent = (mountFn = shallowMount) => {
wrapper = mountFn(RemoveMemberModal, {
wrapper = mountFn(RemoveBillableMemberModal, {
store: createStore(),
stubs: {
GlSprintf,
......@@ -53,13 +53,13 @@ describe('RemoveMemberModal', () => {
it('renders the title with username', () => {
expect(wrapper.attributes('title')).toBe(
`Remove user @${defaultState.memberToRemove.username} from your subscription`,
`Remove user @${defaultState.billableMemberToRemove.username} from your subscription`,
);
});
it('renders the confirmation label with username', () => {
expect(wrapper.find('label').text()).toContain(
defaultState.memberToRemove.username.substring(1),
defaultState.billableMemberToRemove.username.substring(1),
);
});
});
......
......@@ -17,7 +17,7 @@ localVue.use(Vuex);
const actionSpies = {
fetchBillableMembersList: jest.fn(),
resetMembers: jest.fn(),
resetBillableMembers: jest.fn(),
};
const providedFields = {
......@@ -228,17 +228,17 @@ describe('Subscription Seats', () => {
expect(findTableEmptyText()).toBe(EMPTY_TEXT_NO_USERS);
});
it('dispatches the resetMembers action when 1 or 2 characters have been typed', async () => {
expect(actionSpies.resetMembers).not.toHaveBeenCalled();
it('dispatches the.resetBillableMembers action when 1 or 2 characters have been typed', async () => {
expect(actionSpies.resetBillableMembers).not.toHaveBeenCalled();
await findSearchBox().vm.$emit('input', 'a');
expect(actionSpies.resetMembers).toHaveBeenCalledTimes(1);
expect(actionSpies.resetBillableMembers).toHaveBeenCalledTimes(1);
await findSearchBox().vm.$emit('input', 'aa');
expect(actionSpies.resetMembers).toHaveBeenCalledTimes(2);
expect(actionSpies.resetBillableMembers).toHaveBeenCalledTimes(2);
await findSearchBox().vm.$emit('input', 'aaa');
expect(actionSpies.resetMembers).toHaveBeenCalledTimes(2);
expect(actionSpies.resetBillableMembers).toHaveBeenCalledTimes(2);
});
it('dispatches fetchBillableMembersList action when search box is emptied out', async () => {
......
import MockAdapter from 'axios-mock-adapter';
import Api from 'ee/api';
import * as GroupsApi from 'ee/api/groups_api';
import * as actions from 'ee/billings/seat_usage/store/actions';
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 * 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';
......@@ -121,35 +121,35 @@ describe('seats actions', () => {
});
});
describe('resetMembers', () => {
describe('resetBillableMembers', () => {
it('should commit mutation', () => {
testAction({
action: actions.resetMembers,
action: actions.resetBillableMembers,
state,
expectedMutations: [{ type: types.RESET_MEMBERS }],
expectedMutations: [{ type: types.RESET_BILLABLE_MEMBERS }],
});
});
});
describe('setMemberToRemove', () => {
describe('setBillableMemberToRemove', () => {
it('should commit the set member mutation', async () => {
await testAction({
action: actions.setMemberToRemove,
action: actions.setBillableMemberToRemove,
state,
expectedMutations: [{ type: types.SET_MEMBER_TO_REMOVE }],
expectedMutations: [{ type: types.SET_BILLABLE_MEMBER_TO_REMOVE }],
});
});
});
describe('removeMember', () => {
describe('removeBillableMember', () => {
let groupsApiSpy;
beforeEach(() => {
groupsApiSpy = jest.spyOn(GroupsApi, 'removeMemberFromGroup');
groupsApiSpy = jest.spyOn(GroupsApi, 'removeBillableMemberFromGroup');
state = {
namespaceId: 1,
memberToRemove: {
billableMemberToRemove: {
id: 2,
},
};
......@@ -157,14 +157,14 @@ describe('seats actions', () => {
describe('on success', () => {
beforeEach(() => {
mock.onDelete('/api/v4/groups/1/members/2').reply(httpStatusCodes.OK);
mock.onDelete('/api/v4/groups/1/billable_members/2').reply(httpStatusCodes.OK);
});
it('dispatches the removeMemberSuccess action', async () => {
it('dispatches the removeBillableMemberSuccess action', async () => {
await testAction({
action: actions.removeMember,
action: actions.removeBillableMember,
state,
expectedActions: [{ type: 'removeMemberSuccess' }],
expectedActions: [{ type: 'removeBillableMemberSuccess' }],
});
expect(groupsApiSpy).toHaveBeenCalled();
......@@ -173,14 +173,16 @@ describe('seats actions', () => {
describe('on error', () => {
beforeEach(() => {
mock.onDelete('/api/v4/groups/1/members/2').reply(httpStatusCodes.UNPROCESSABLE_ENTITY);
mock
.onDelete('/api/v4/groups/1/billable_members/2')
.reply(httpStatusCodes.UNPROCESSABLE_ENTITY);
});
it('dispatches the removeMemberError action', async () => {
it('dispatches the removeBillableMemberError action', async () => {
await testAction({
action: actions.removeMember,
action: actions.removeBillableMember,
state,
expectedActions: [{ type: 'removeMemberError' }],
expectedActions: [{ type: 'removeBillableMemberError' }],
});
expect(groupsApiSpy).toHaveBeenCalled();
......@@ -188,13 +190,13 @@ describe('seats actions', () => {
});
});
describe('removeMemberSuccess', () => {
describe('removeBillableMemberSuccess', () => {
it('dispatches fetchBillableMembersList', async () => {
await testAction({
action: actions.removeMemberSuccess,
action: actions.removeBillableMemberSuccess,
state,
expectedActions: [{ type: 'fetchBillableMembersList' }],
expectedMutations: [{ type: types.REMOVE_MEMBER_SUCCESS }],
expectedMutations: [{ type: types.REMOVE_BILLABLE_MEMBER_SUCCESS }],
});
expect(createFlash).toHaveBeenCalledWith({
......@@ -204,12 +206,12 @@ describe('seats actions', () => {
});
});
describe('removeMemberError', () => {
describe('removeBillableMemberError', () => {
it('commits remove member error', async () => {
await testAction({
action: actions.removeMemberError,
action: actions.removeBillableMemberError,
state,
expectedMutations: [{ type: types.REMOVE_MEMBER_ERROR }],
expectedMutations: [{ type: types.REMOVE_BILLABLE_MEMBER_ERROR }],
});
expect(createFlash).toHaveBeenCalledWith({
......
......@@ -68,10 +68,10 @@ describe('EE billings seats module mutations', () => {
});
});
describe(types.RESET_MEMBERS, () => {
describe(types.RESET_BILLABLE_MEMBERS, () => {
beforeEach(() => {
mutations[types.RECEIVE_BILLABLE_MEMBERS_SUCCESS](state, mockDataSeats);
mutations[types.RESET_MEMBERS](state);
mutations[types.RESET_BILLABLE_MEMBERS](state);
});
it('resets members state', () => {
......@@ -96,42 +96,42 @@ describe('EE billings seats module mutations', () => {
mutations[types.RECEIVE_BILLABLE_MEMBERS_SUCCESS](state, mockDataSeats);
});
describe(types.SET_MEMBER_TO_REMOVE, () => {
describe(types.SET_BILLABLE_MEMBER_TO_REMOVE, () => {
it('sets the member to remove', () => {
mutations[types.SET_MEMBER_TO_REMOVE](state, memberToRemove);
mutations[types.SET_BILLABLE_MEMBER_TO_REMOVE](state, memberToRemove);
expect(state.memberToRemove).toMatchObject(memberToRemove);
expect(state.billableMemberToRemove).toMatchObject(memberToRemove);
});
});
describe(types.REMOVE_MEMBER, () => {
describe(types.REMOVE_BILLABLE_MEMBER, () => {
it('sets state to loading', () => {
mutations[types.REMOVE_MEMBER](state, memberToRemove);
mutations[types.REMOVE_BILLABLE_MEMBER](state, memberToRemove);
expect(state).toMatchObject({ isLoading: true, hasError: false });
});
});
describe(types.REMOVE_MEMBER_SUCCESS, () => {
describe(types.REMOVE_BILLABLE_MEMBER_SUCCESS, () => {
it('sets state to successfull', () => {
mutations[types.REMOVE_MEMBER_SUCCESS](state, memberToRemove);
mutations[types.REMOVE_BILLABLE_MEMBER_SUCCESS](state, memberToRemove);
expect(state).toMatchObject({
isLoading: false,
hasError: false,
memberToRemove: null,
billableMemberToRemove: null,
});
});
});
describe(types.REMOVE_MEMBER_ERROR, () => {
describe(types.REMOVE_BILLABLE_MEMBER_ERROR, () => {
it('sets state to errored', () => {
mutations[types.REMOVE_MEMBER_ERROR](state, memberToRemove);
mutations[types.REMOVE_BILLABLE_MEMBER_ERROR](state, memberToRemove);
expect(state).toMatchObject({
isLoading: false,
hasError: true,
memberToRemove: null,
billableMemberToRemove: null,
});
});
});
......
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