Commit f49b2a83 authored by Illya Klymov's avatar Illya Klymov

Merge branch 'add-invite-non-member-to-modal' into 'master'

Add invite by email to modal

See merge request gitlab-org/gitlab!45946
parents 501c6240 27b24dba
......@@ -13,6 +13,7 @@ const Api = {
groupMilestonesPath: '/api/:version/groups/:id/milestones',
subgroupsPath: '/api/:version/groups/:id/subgroups',
namespacesPath: '/api/:version/namespaces.json',
groupInvitationsPath: '/api/:version/groups/:id/invitations',
groupPackagesPath: '/api/:version/groups/:id/packages',
projectPackagesPath: '/api/:version/projects/:id/packages',
projectPackagePath: '/api/:version/projects/:id/packages/:package_id',
......@@ -23,6 +24,7 @@ const Api = {
projectLabelsPath: '/:namespace_path/:project_path/-/labels',
projectFileSchemaPath: '/:namespace_path/:project_path/-/schema/:ref/:filename',
projectUsersPath: '/api/:version/projects/:id/users',
projectInvitationsPath: '/api/:version/projects/:id/invitations',
projectMembersPath: '/api/:version/projects/:id/members',
projectMergeRequestsPath: '/api/:version/projects/:id/merge_requests',
projectMergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid',
......@@ -127,12 +129,18 @@ const Api = {
});
},
inviteGroupMember(id, data) {
addGroupMembersByUserId(id, data) {
const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id));
return axios.post(url, data);
},
inviteGroupMembersByEmail(id, data) {
const url = Api.buildUrl(this.groupInvitationsPath).replace(':id', encodeURIComponent(id));
return axios.post(url, data);
},
groupMilestones(id, options) {
const url = Api.buildUrl(this.groupMilestonesPath).replace(':id', encodeURIComponent(id));
......@@ -217,12 +225,18 @@ const Api = {
.then(({ data }) => data);
},
inviteProjectMembers(id, data) {
addProjectMembersByUserId(id, data) {
const url = Api.buildUrl(this.projectMembersPath).replace(':id', encodeURIComponent(id));
return axios.post(url, data);
},
inviteProjectMembersByEmail(id, data) {
const url = Api.buildUrl(this.projectInvitationsPath).replace(':id', encodeURIComponent(id));
return axios.post(url, data);
},
// Return single project
project(projectPath) {
const url = Api.buildUrl(Api.projectPath).replace(':id', encodeURIComponent(projectPath));
......
......@@ -9,6 +9,7 @@ import {
GlButton,
GlFormInput,
} from '@gitlab/ui';
import { partition, isString } from 'lodash';
import eventHub from '../event_hub';
import { s__, __, sprintf } from '~/locale';
import Api from '~/api';
......@@ -58,7 +59,7 @@ export default {
visible: true,
modalId: 'invite-members-modal',
selectedAccessLevel: this.defaultAccessLevel,
newUsersToInvite: '',
newUsersToInvite: [],
selectedDate: undefined,
};
},
......@@ -79,13 +80,12 @@ export default {
return {
onComplete: () => {
this.selectedAccessLevel = this.defaultAccessLevel;
this.newUsersToInvite = '';
this.newUsersToInvite = [];
},
};
},
postData() {
basePostData() {
return {
user_id: this.newUsersToInvite,
access_level: this.selectedAccessLevel,
expires_at: this.selectedDate,
format: 'json',
......@@ -101,6 +101,17 @@ export default {
eventHub.$on('openModal', this.openModal);
},
methods: {
partitionNewUsersToInvite() {
const [usersToInviteByEmail, usersToAddById] = partition(
this.newUsersToInvite,
(user) => isString(user.id) && user.id.includes('user-defined-token'),
);
return [
usersToInviteByEmail.map((user) => user.name).join(','),
usersToAddById.map((user) => user.id).join(','),
];
},
openModal() {
this.$root.$emit('bv::show::modal', this.modalId);
},
......@@ -108,7 +119,7 @@ export default {
this.$root.$emit('bv::hide::modal', this.modalId);
},
sendInvite() {
this.submitForm(this.postData);
this.submitForm();
this.closeModal();
},
cancelInvite() {
......@@ -120,15 +131,33 @@ export default {
changeSelectedItem(item) {
this.selectedAccessLevel = item;
},
submitForm(formData) {
if (this.isProject) {
return Api.inviteProjectMembers(this.id, formData)
.then(this.showToastMessageSuccess)
.catch(this.showToastMessageError);
submitForm() {
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
const promises = [];
if (usersToInviteByEmail !== '') {
const apiInviteByEmail = this.isProject
? Api.inviteProjectMembersByEmail.bind(Api)
: Api.inviteGroupMembersByEmail.bind(Api);
promises.push(apiInviteByEmail(this.id, this.inviteByEmailPostData(usersToInviteByEmail)));
}
return Api.inviteGroupMember(this.id, formData)
.then(this.showToastMessageSuccess)
.catch(this.showToastMessageError);
if (usersToAddById !== '') {
const apiAddByUserId = this.isProject
? Api.addProjectMembersByUserId.bind(Api)
: Api.addGroupMembersByUserId.bind(Api);
promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById)));
}
Promise.all(promises).then(this.showToastMessageSuccess).catch(this.showToastMessageError);
},
inviteByEmailPostData(usersToInviteByEmail) {
return { ...this.basePostData, email: usersToInviteByEmail };
},
addByUserIdPostData(usersToAddById) {
return { ...this.basePostData, user_id: usersToAddById };
},
showToastMessageSuccess() {
this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
......
<script>
import { debounce } from 'lodash';
import { GlTokenSelector, GlAvatar, GlAvatarLabeled } from '@gitlab/ui';
import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
import { USER_SEARCH_DELAY } from '../constants';
import Api from '~/api';
......@@ -9,6 +10,7 @@ export default {
GlTokenSelector,
GlAvatar,
GlAvatarLabeled,
GlSprintf,
},
props: {
placeholder: {
......@@ -32,12 +34,10 @@ export default {
};
},
computed: {
newUsersToInvite() {
return this.selectedTokens
.map((obj) => {
return obj.id;
})
.join(',');
emailIsValid() {
const regex = /.+@/;
return this.query.match(regex) !== null;
},
placeholderText() {
if (this.selectedTokens.length === 0) {
......@@ -69,7 +69,7 @@ export default {
});
}, USER_SEARCH_DELAY),
handleInput() {
this.$emit('input', this.newUsersToInvite);
this.$emit('input', this.selectedTokens);
},
handleBlur() {
this.hideDropdownWithNoItems = false;
......@@ -86,6 +86,9 @@ export default {
},
},
queryOptions: { exclude_internal: true, active: true },
i18n: {
inviteTextMessage: __('Invite "%{email}" by email'),
},
};
</script>
......@@ -94,7 +97,7 @@ export default {
v-model="selectedTokens"
:dropdown-items="users"
:loading="loading"
:allow-user-defined-tokens="false"
:allow-user-defined-tokens="emailIsValid"
:hide-dropdown-with-no-items="hideDropdownWithNoItems"
:placeholder="placeholderText"
:aria-labelledby="ariaLabelledby"
......@@ -116,5 +119,13 @@ export default {
:sub-label="dropdownItem.username"
/>
</template>
<template #user-defined-token-content="{ inputText: email }">
<gl-sprintf :message="$options.i18n.inviteTextMessage">
<template #email>
<span>{{ email }}</span>
</template>
</gl-sprintf>
</template>
</gl-token-selector>
</template>
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
import { parseBoolean } from '~/lib/utils/common_utils';
import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
Vue.use(GlToast);
......@@ -17,6 +18,7 @@ export default function initInviteMembersModal() {
createElement(InviteMembersModal, {
props: {
...el.dataset,
isProject: parseBoolean(el.dataset.isProject),
accessLevels: JSON.parse(el.dataset.accessLevels),
},
}),
......
- if invite_members_allowed?(group)
.js-invite-members-modal{ data: { id: group.id,
name: group.name,
is_project: false,
is_project: 'false',
access_levels: GroupMember.access_level_roles.to_json,
default_access_level: Gitlab::Access::GUEST,
help_link: help_page_url('user/permissions') } }
- if invite_members_allowed?(project.group)
.js-invite-members-modal{ data: { id: project.id,
name: project.name,
is_project: true,
is_project: 'true',
access_levels: GroupMember.access_level_roles.to_json,
default_access_level: Gitlab::Access::GUEST,
help_link: help_page_url('user/permissions') } }
......@@ -15353,6 +15353,9 @@ msgstr ""
msgid "Invite"
msgstr ""
msgid "Invite \"%{email}\" by email"
msgstr ""
msgid "Invite \"%{trimmed}\" by email"
msgstr ""
......
......@@ -167,6 +167,50 @@ describe('Api', () => {
});
});
describe('addGroupMembersByUserId', () => {
it('adds an existing User as a new Group Member by User ID', () => {
const groupId = 1;
const expectedUserId = 2;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/members`;
const params = {
user_id: expectedUserId,
access_level: 10,
expires_at: undefined,
};
mock.onPost(expectedUrl).reply(200, {
id: expectedUserId,
state: 'active',
});
return Api.addGroupMembersByUserId(groupId, params).then(({ data }) => {
expect(data.id).toBe(expectedUserId);
expect(data.state).toBe('active');
});
});
});
describe('inviteGroupMembersByEmail', () => {
it('invites a new email address to create a new User and become a Group Member', () => {
const groupId = 1;
const email = 'email@example.com';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/invitations`;
const params = {
email,
access_level: 10,
expires_at: undefined,
};
mock.onPost(expectedUrl).reply(200, {
status: 'success',
});
return Api.inviteGroupMembersByEmail(groupId, params).then(({ data }) => {
expect(data.status).toBe('success');
});
});
});
describe('groupMilestones', () => {
it('fetches group milestones', (done) => {
const groupId = '16';
......@@ -458,6 +502,50 @@ describe('Api', () => {
});
});
describe('addProjectMembersByUserId', () => {
it('adds an existing User as a new Project Member by User ID', () => {
const projectId = 1;
const expectedUserId = 2;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/members`;
const params = {
user_id: expectedUserId,
access_level: 10,
expires_at: undefined,
};
mock.onPost(expectedUrl).reply(200, {
id: expectedUserId,
state: 'active',
});
return Api.addProjectMembersByUserId(projectId, params).then(({ data }) => {
expect(data.id).toBe(expectedUserId);
expect(data.state).toBe('active');
});
});
});
describe('inviteProjectMembersByEmail', () => {
it('invites a new email address to create a new User and become a Project Member', () => {
const projectId = 1;
const expectedEmail = 'email@example.com';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/invitations`;
const params = {
email: expectedEmail,
access_level: 10,
expires_at: undefined,
};
mock.onPost(expectedUrl).reply(200, {
status: 'success',
});
return Api.inviteProjectMembersByEmail(projectId, params).then(({ data }) => {
expect(data.status).toBe('success');
});
});
});
describe('newLabel', () => {
it('creates a new label', (done) => {
const namespace = 'some namespace';
......
import { shallowMount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem, GlDatepicker, GlSprintf, GlLink, GlModal } from '@gitlab/ui';
import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import Api from '~/api';
import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
......@@ -11,6 +12,15 @@ const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, O
const defaultAccessLevel = '10';
const helpLink = 'https://example.com';
const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' };
const user2 = { id: 2, name: 'Name Two', username: 'one_2', avatar_url: '' };
const user3 = {
id: 'user-defined-token',
name: 'email@example.com',
username: 'one_2',
avatar_url: '',
};
const createComponent = (data = {}) => {
return shallowMount(InviteMembersModal, {
propsData: {
......@@ -50,6 +60,7 @@ describe('InviteMembersModal', () => {
const findLink = () => wrapper.find(GlLink);
const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
const findInviteButton = () => wrapper.find({ ref: 'inviteButton' });
const clickInviteButton = () => findInviteButton().vm.$emit('click');
describe('rendering the modal', () => {
beforeEach(() => {
......@@ -92,78 +103,184 @@ describe('InviteMembersModal', () => {
});
describe('submitting the invite form', () => {
const apiErrorMessage = 'Member already exists';
describe('when inviting an existing user to group by user ID', () => {
const postData = {
user_id: '1',
access_level: '10',
expires_at: new Date(),
expires_at: undefined,
format: 'json',
};
describe('when the invite was sent successfully', () => {
describe('when invites are sent successfully', () => {
beforeEach(() => {
wrapper = createComponent();
wrapper = createComponent({ newUsersToInvite: [user1] });
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMember').mockResolvedValue({ data: postData });
jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
wrapper.vm.submitForm(postData);
clickInviteButton();
});
it('calls Api addGroupMembersByUserId with the correct params', () => {
expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, postData);
});
it('displays the successful toastMessage', () => {
const toastMessageSuccessful = 'Members were successfully added';
expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled();
});
});
describe('when the invite received an api error message', () => {
beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: [user1] });
wrapper.vm.$toast = { show: jest.fn() };
jest
.spyOn(Api, 'addGroupMembersByUserId')
.mockRejectedValue({ response: { data: { message: apiErrorMessage } } });
jest.spyOn(wrapper.vm, 'showToastMessageError');
clickInviteButton();
});
it('displays the apiErrorMessage in the toastMessage', async () => {
await waitForPromises();
expect(wrapper.vm.showToastMessageError).toHaveBeenCalledWith({
response: { data: { message: apiErrorMessage } },
});
});
});
describe('when any invite failed for any other reason', () => {
beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: [user1, user2] });
wrapper.vm.$toast = { show: jest.fn() };
jest
.spyOn(Api, 'addGroupMembersByUserId')
.mockRejectedValue({ response: { data: { success: false } } });
jest.spyOn(wrapper.vm, 'showToastMessageError');
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
toastMessageSuccessful,
wrapper.vm.toastOptions,
);
clickInviteButton();
});
it('calls Api inviteGroupMember with the correct params', () => {
expect(Api.inviteGroupMember).toHaveBeenCalledWith(id, postData);
it('displays the generic error toastMessage', async () => {
await waitForPromises();
expect(wrapper.vm.showToastMessageError).toHaveBeenCalled();
});
});
});
describe('when inviting a new user by email address', () => {
const postData = {
access_level: '10',
expires_at: undefined,
email: 'email@example.com',
format: 'json',
};
describe('when invites are sent successfully', () => {
beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: [user3] });
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
clickInviteButton();
});
it('calls Api inviteGroupMembersByEmail with the correct params', () => {
expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, postData);
});
describe('when sending the invite for a single member returned an api error', () => {
const apiErrorMessage = 'Members already exists';
it('displays the successful toastMessage', () => {
expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled();
});
});
describe('when any invite failed for any reason', () => {
beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: '123' });
wrapper = createComponent({ newUsersToInvite: [user1, user2] });
wrapper.vm.$toast = { show: jest.fn() };
jest
.spyOn(Api, 'inviteGroupMember')
.mockRejectedValue({ response: { data: { message: apiErrorMessage } } });
.spyOn(Api, 'addGroupMembersByUserId')
.mockRejectedValue({ response: { data: { success: false } } });
jest.spyOn(wrapper.vm, 'showToastMessageError');
clickInviteButton();
});
it('displays the generic error toastMessage', async () => {
await waitForPromises();
expect(wrapper.vm.showToastMessageError).toHaveBeenCalled();
});
});
});
findInviteButton().vm.$emit('click');
describe('when inviting members and non-members in same click', () => {
const postData = {
access_level: '10',
expires_at: undefined,
format: 'json',
};
const emailPostData = { ...postData, email: 'email@example.com' };
const idPostData = { ...postData, user_id: '1' };
describe('when invites are sent successfully', () => {
beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: [user1, user3] });
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
clickInviteButton();
});
it('displays the api error message for the toastMessage', () => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
apiErrorMessage,
wrapper.vm.toastOptions,
);
it('calls Api inviteGroupMembersByEmail with the correct params', () => {
expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, emailPostData);
});
it('calls Api addGroupMembersByUserId with the correct params', () => {
expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, idPostData);
});
describe('when sending the invite for multiple members returned any error', () => {
const genericErrorMessage = 'Some of the members could not be added';
it('displays the successful toastMessage', () => {
expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled();
});
});
describe('when any invite failed for any reason', () => {
beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: '123' });
wrapper = createComponent({ newUsersToInvite: [user1, user3] });
wrapper.vm.$toast = { show: jest.fn() };
jest
.spyOn(Api, 'inviteGroupMember')
.spyOn(Api, 'inviteGroupMembersByEmail')
.mockRejectedValue({ response: { data: { success: false } } });
findInviteButton().vm.$emit('click');
jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
jest.spyOn(wrapper.vm, 'showToastMessageError');
clickInviteButton();
});
it('displays the expected toastMessage', () => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
genericErrorMessage,
wrapper.vm.toastOptions,
);
it('displays the generic error toastMessage', async () => {
await waitForPromises();
expect(wrapper.vm.showToastMessageError).toHaveBeenCalled();
});
});
});
});
......
......@@ -8,8 +8,8 @@ import MembersTokenSelect from '~/invite_members/components/members_token_select
const label = 'testgroup';
const placeholder = 'Search for a member';
const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' };
const user2 = { id: 2, name: 'Name Two', username: 'two_2', avatar_url: '' };
const user1 = { id: 1, name: 'John Smith', username: 'one_1', avatar_url: '' };
const user2 = { id: 2, name: 'Jane Doe', username: 'two_2', avatar_url: '' };
const allUsers = [user1, user2];
const createComponent = () => {
......@@ -77,9 +77,14 @@ describe('MembersTokenSelect', () => {
});
describe('when text input is typed in', () => {
let tokenSelector;
beforeEach(() => {
tokenSelector = findTokenSelector();
});
it('calls the API with search parameter', async () => {
const searchParam = 'One';
const tokenSelector = findTokenSelector();
tokenSelector.vm.$emit('text-input', searchParam);
......@@ -88,16 +93,23 @@ describe('MembersTokenSelect', () => {
expect(Api.users).toHaveBeenCalledWith(searchParam, wrapper.vm.$options.queryOptions);
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
});
describe('when input text is an email', () => {
it('allows user defined tokens', async () => {
tokenSelector.vm.$emit('text-input', 'foo@bar.com');
await nextTick();
expect(tokenSelector.props('allowUserDefinedTokens')).toBe(true);
});
});
});
describe('when user is selected', () => {
it('emits `input` event with selected users', () => {
findTokenSelector().vm.$emit('input', [
{ id: 1, name: 'John Smith' },
{ id: 2, name: 'Jane Doe' },
]);
findTokenSelector().vm.$emit('input', [user1, user2]);
expect(wrapper.emitted().input[0][0]).toBe('1,2');
expect(wrapper.emitted().input[0][0]).toEqual([user1, user2]);
});
});
});
......
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