Commit ede344a7 authored by Jackie Fraser's avatar Jackie Fraser Committed by Nicolò Maria Mezzopera

Add username search to invite members modal

parent 573a263c
......@@ -6,13 +6,13 @@ import {
GlDatepicker,
GlLink,
GlSprintf,
GlSearchBoxByType,
GlButton,
GlFormInput,
} from '@gitlab/ui';
import eventHub from '../event_hub';
import { s__, sprintf } from '~/locale';
import Api from '~/api';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
export default {
name: 'InviteMembersModal',
......@@ -23,9 +23,9 @@ export default {
GlDropdown,
GlDropdownItem,
GlSprintf,
GlSearchBoxByType,
GlButton,
GlFormInput,
MembersTokenSelect,
},
props: {
groupId: {
......@@ -129,44 +129,45 @@ export default {
},
labels: {
modalTitle: s__('InviteMembersModal|Invite team members'),
userToInvite: s__('InviteMembersModal|GitLab member or Email address'),
newUsersToInvite: s__('InviteMembersModal|GitLab member or Email address'),
userPlaceholder: s__('InviteMembersModal|Search for members to invite'),
accessLevel: s__('InviteMembersModal|Choose a role permission'),
accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'),
toastMessageSuccessful: s__('InviteMembersModal|Users were succesfully added'),
toastMessageUnsuccessful: s__('InviteMembersModal|User not invited. Feature coming soon!'),
toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'),
toastMessageUnsuccessful: s__('InviteMembersModal|Some of the members could not be added'),
readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`),
inviteButtonText: s__('InviteMembersModal|Invite'),
cancelButtonText: s__('InviteMembersModal|Cancel'),
headerCloseLabel: s__('InviteMembersModal|Close invite team members'),
},
membersTokenSelectLabelId: 'invite-members-input',
};
</script>
<template>
<gl-modal :modal-id="modalId" size="sm" :title="$options.labels.modalTitle">
<gl-modal
:modal-id="modalId"
size="sm"
:title="$options.labels.modalTitle"
:header-close-label="$options.labels.headerCloseLabel"
>
<div class="gl-ml-5 gl-mr-5">
<div>{{ introText }}</div>
<label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.userToInvite }}</label>
<label :id="$options.membersTokenSelectLabelId" class="gl-font-weight-bold gl-mt-5">{{
$options.labels.newUsersToInvite
}}</label>
<div class="gl-mt-2">
<gl-search-box-by-type
<members-token-select
v-model="newUsersToInvite"
:label="$options.labels.newUsersToInvite"
:aria-labelledby="$options.membersTokenSelectLabelId"
:placeholder="$options.labels.userPlaceholder"
type="text"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/>
</div>
<label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.accessLevel }}</label>
<div class="gl-mt-2 gl-w-half gl-xs-w-full">
<gl-dropdown
menu-class="dropdown-menu-selectable"
class="gl-shadow-none gl-w-full"
v-bind="$attrs"
:text="selectedRoleName"
>
<gl-dropdown class="gl-shadow-none gl-w-full" v-bind="$attrs" :text="selectedRoleName">
<template v-for="(key, item) in accessLevels">
<gl-dropdown-item
:key="key"
......@@ -215,9 +216,13 @@ export default {
{{ $options.labels.cancelButtonText }}
</gl-button>
<div class="gl-mr-3"></div>
<gl-button ref="inviteButton" variant="success" @click="sendInvite">{{
$options.labels.inviteButtonText
}}</gl-button>
<gl-button
ref="inviteButton"
:disabled="!newUsersToInvite"
variant="success"
@click="sendInvite"
>{{ $options.labels.inviteButtonText }}</gl-button
>
</div>
</template>
</gl-modal>
......
<script>
import { debounce } from 'lodash';
import { GlTokenSelector, GlAvatar, GlAvatarLabeled } from '@gitlab/ui';
import { USER_SEARCH_DELAY } from '../constants';
import Api from '~/api';
export default {
components: {
GlTokenSelector,
GlAvatar,
GlAvatarLabeled,
},
props: {
placeholder: {
type: String,
required: false,
default: '',
},
ariaLabelledby: {
type: String,
required: true,
},
},
data() {
return {
loading: false,
query: '',
users: [],
selectedTokens: [],
hasBeenFocused: false,
hideDropdownWithNoItems: true,
};
},
computed: {
newUsersToInvite() {
return this.selectedTokens
.map(obj => {
return obj.id;
})
.join(',');
},
placeholderText() {
if (this.selectedTokens.length === 0) {
return this.placeholder;
}
return '';
},
},
methods: {
handleTextInput(query) {
this.hideDropdownWithNoItems = false;
this.query = query;
this.loading = true;
this.retrieveUsers(query);
},
retrieveUsers: debounce(function debouncedRetrieveUsers() {
return Api.users(this.query, this.$options.queryOptions)
.then(response => {
this.users = response.data.map(token => ({
id: token.id,
name: token.name,
username: token.username,
avatar_url: token.avatar_url,
}));
this.loading = false;
})
.catch(() => {
this.loading = false;
});
}, USER_SEARCH_DELAY),
handleInput() {
this.$emit('input', this.newUsersToInvite);
},
handleBlur() {
this.hideDropdownWithNoItems = false;
},
handleFocus() {
// The modal auto-focuses on the input when opened.
// This prevents the dropdown from opening when the modal opens.
if (this.hasBeenFocused) {
this.loading = true;
this.retrieveUsers();
}
this.hasBeenFocused = true;
},
},
queryOptions: { exclude_internal: true, active: true },
};
</script>
<template>
<gl-token-selector
v-model="selectedTokens"
:dropdown-items="users"
:loading="loading"
:allow-user-defined-tokens="false"
:hide-dropdown-with-no-items="hideDropdownWithNoItems"
:placeholder="placeholderText"
:aria-labelledby="ariaLabelledby"
@blur="handleBlur"
@text-input="handleTextInput"
@input="handleInput"
@focus="handleFocus"
>
<template #token-content="{ token }">
<gl-avatar v-if="token.avatar_url" :src="token.avatar_url" :size="16" />
{{ token.name }}
</template>
<template #dropdown-item-content="{ dropdownItem }">
<gl-avatar-labeled
:src="dropdownItem.avatar_url"
:size="32"
:label="dropdownItem.name"
:sub-label="dropdownItem.username"
/>
</template>
</gl-token-selector>
</template>
export const USER_SEARCH_DELAY = 200;
......@@ -14729,6 +14729,9 @@ msgstr ""
msgid "InviteMembersModal|Choose a role permission"
msgstr ""
msgid "InviteMembersModal|Close invite team members"
msgstr ""
msgid "InviteMembersModal|GitLab member or Email address"
msgstr ""
......@@ -14738,13 +14741,13 @@ msgstr ""
msgid "InviteMembersModal|Invite team members"
msgstr ""
msgid "InviteMembersModal|Search for members to invite"
msgid "InviteMembersModal|Members were successfully added"
msgstr ""
msgid "InviteMembersModal|User not invited. Feature coming soon!"
msgid "InviteMembersModal|Search for members to invite"
msgstr ""
msgid "InviteMembersModal|Users were succesfully added"
msgid "InviteMembersModal|Some of the members could not be added"
msgstr ""
msgid "InviteMembersModal|You're inviting members to the %{group_name} group"
......
......@@ -9,7 +9,7 @@ const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, O
const defaultAccessLevel = '10';
const helpLink = 'https://example.com';
const createComponent = () => {
const createComponent = (data = {}) => {
return shallowMount(InviteMembersModal, {
propsData: {
groupId,
......@@ -18,9 +18,14 @@ const createComponent = () => {
defaultAccessLevel,
helpLink,
},
data() {
return data;
},
stubs: {
GlSprintf,
'gl-modal': '<div><slot name="modal-footer"></slot><slot></slot></div>',
'gl-dropdown': true,
'gl-dropdown-item': true,
GlSprintf,
},
});
};
......@@ -34,7 +39,7 @@ describe('InviteMembersModal', () => {
});
const findDropdown = () => wrapper.find(GlDropdown);
const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
const findDropdownItems = () => findDropdown().findAll(GlDropdownItem);
const findDatepicker = () => wrapper.find(GlDatepicker);
const findLink = () => wrapper.find(GlLink);
const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
......@@ -88,25 +93,69 @@ describe('InviteMembersModal', () => {
format: 'json',
};
beforeEach(() => {
wrapper = createComponent();
describe('when the invite was sent successfully', () => {
beforeEach(() => {
wrapper = createComponent();
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMember').mockResolvedValue({ data: postData });
jest.spyOn(Api, 'inviteGroupMember').mockResolvedValue({ data: postData });
wrapper.vm.$toast = { show: jest.fn() };
wrapper.vm.submitForm(postData);
});
wrapper.vm.submitForm(postData);
it('displays the successful toastMessage', () => {
const toastMessageSuccessful = 'Members were successfully added';
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
toastMessageSuccessful,
wrapper.vm.toastOptions,
);
});
it('calls Api inviteGroupMember with the correct params', () => {
expect(Api.inviteGroupMember).toHaveBeenCalledWith(groupId, postData);
});
});
it('calls Api inviteGroupMember with the correct params', () => {
expect(Api.inviteGroupMember).toHaveBeenCalledWith(groupId, postData);
describe('when sending the invite for a single member returned an api error', () => {
const apiErrorMessage = 'Members already exists';
beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: '123' });
wrapper.vm.$toast = { show: jest.fn() };
jest
.spyOn(Api, 'inviteGroupMember')
.mockRejectedValue({ response: { data: { message: apiErrorMessage } } });
findInviteButton().vm.$emit('click');
});
it('displays the api error message for the toastMessage', () => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
apiErrorMessage,
wrapper.vm.toastOptions,
);
});
});
describe('when the invite was sent successfully', () => {
const toastMessageSuccessful = 'Users were succesfully added';
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', () => {
beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: '123' });
wrapper.vm.$toast = { show: jest.fn() };
jest
.spyOn(Api, 'inviteGroupMember')
.mockRejectedValue({ response: { data: { success: false } } });
findInviteButton().vm.$emit('click');
});
it('displays the expected toastMessage', () => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
toastMessageSuccessful,
genericErrorMessage,
wrapper.vm.toastOptions,
);
});
......
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { GlTokenSelector } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import Api from '~/api';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
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 allUsers = [user1, user2];
const createComponent = () => {
return shallowMount(MembersTokenSelect, {
propsData: {
ariaLabelledby: label,
placeholder,
},
});
};
describe('MembersTokenSelect', () => {
let wrapper;
beforeEach(() => {
jest.spyOn(Api, 'users').mockResolvedValue({ data: allUsers });
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findTokenSelector = () => wrapper.find(GlTokenSelector);
describe('rendering the token-selector component', () => {
it('renders with the correct props', () => {
const expectedProps = {
ariaLabelledby: label,
placeholder,
};
expect(findTokenSelector().props()).toEqual(expect.objectContaining(expectedProps));
});
});
describe('users', () => {
describe('when input is focused for the first time (modal auto-focus)', () => {
it('does not call the API', async () => {
findTokenSelector().vm.$emit('focus');
await waitForPromises();
expect(Api.users).not.toHaveBeenCalled();
});
});
describe('when input is manually focused', () => {
it('calls the API and sets dropdown items as request result', async () => {
const tokenSelector = findTokenSelector();
tokenSelector.vm.$emit('focus');
tokenSelector.vm.$emit('blur');
tokenSelector.vm.$emit('focus');
await waitForPromises();
expect(tokenSelector.props('dropdownItems')).toMatchObject(allUsers);
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
});
});
describe('when text input is typed in', () => {
it('calls the API with search parameter', async () => {
const searchParam = 'One';
const tokenSelector = findTokenSelector();
tokenSelector.vm.$emit('text-input', searchParam);
await waitForPromises();
expect(Api.users).toHaveBeenCalledWith(searchParam, wrapper.vm.$options.queryOptions);
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
});
});
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' },
]);
expect(wrapper.emitted().input[0][0]).toBe('1,2');
});
});
});
describe('when text input is blurred', () => {
it('clears text input', async () => {
const tokenSelector = findTokenSelector();
tokenSelector.vm.$emit('blur');
await nextTick();
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
});
});
});
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