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 { ...@@ -6,13 +6,13 @@ import {
GlDatepicker, GlDatepicker,
GlLink, GlLink,
GlSprintf, GlSprintf,
GlSearchBoxByType,
GlButton, GlButton,
GlFormInput, GlFormInput,
} from '@gitlab/ui'; } from '@gitlab/ui';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import Api from '~/api'; import Api from '~/api';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
export default { export default {
name: 'InviteMembersModal', name: 'InviteMembersModal',
...@@ -23,9 +23,9 @@ export default { ...@@ -23,9 +23,9 @@ export default {
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
GlSprintf, GlSprintf,
GlSearchBoxByType,
GlButton, GlButton,
GlFormInput, GlFormInput,
MembersTokenSelect,
}, },
props: { props: {
groupId: { groupId: {
...@@ -129,44 +129,45 @@ export default { ...@@ -129,44 +129,45 @@ export default {
}, },
labels: { labels: {
modalTitle: s__('InviteMembersModal|Invite team members'), 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'), userPlaceholder: s__('InviteMembersModal|Search for members to invite'),
accessLevel: s__('InviteMembersModal|Choose a role permission'), accessLevel: s__('InviteMembersModal|Choose a role permission'),
accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'), accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'),
toastMessageSuccessful: s__('InviteMembersModal|Users were succesfully added'), toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'),
toastMessageUnsuccessful: s__('InviteMembersModal|User not invited. Feature coming soon!'), toastMessageUnsuccessful: s__('InviteMembersModal|Some of the members could not be added'),
readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`), readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`),
inviteButtonText: s__('InviteMembersModal|Invite'), inviteButtonText: s__('InviteMembersModal|Invite'),
cancelButtonText: s__('InviteMembersModal|Cancel'), cancelButtonText: s__('InviteMembersModal|Cancel'),
headerCloseLabel: s__('InviteMembersModal|Close invite team members'),
}, },
membersTokenSelectLabelId: 'invite-members-input',
}; };
</script> </script>
<template> <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 class="gl-ml-5 gl-mr-5">
<div>{{ introText }}</div> <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"> <div class="gl-mt-2">
<gl-search-box-by-type <members-token-select
v-model="newUsersToInvite" v-model="newUsersToInvite"
:label="$options.labels.newUsersToInvite"
:aria-labelledby="$options.membersTokenSelectLabelId"
:placeholder="$options.labels.userPlaceholder" :placeholder="$options.labels.userPlaceholder"
type="text"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/> />
</div> </div>
<label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.accessLevel }}</label> <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"> <div class="gl-mt-2 gl-w-half gl-xs-w-full">
<gl-dropdown <gl-dropdown class="gl-shadow-none gl-w-full" v-bind="$attrs" :text="selectedRoleName">
menu-class="dropdown-menu-selectable"
class="gl-shadow-none gl-w-full"
v-bind="$attrs"
:text="selectedRoleName"
>
<template v-for="(key, item) in accessLevels"> <template v-for="(key, item) in accessLevels">
<gl-dropdown-item <gl-dropdown-item
:key="key" :key="key"
...@@ -215,9 +216,13 @@ export default { ...@@ -215,9 +216,13 @@ export default {
{{ $options.labels.cancelButtonText }} {{ $options.labels.cancelButtonText }}
</gl-button> </gl-button>
<div class="gl-mr-3"></div> <div class="gl-mr-3"></div>
<gl-button ref="inviteButton" variant="success" @click="sendInvite">{{ <gl-button
$options.labels.inviteButtonText ref="inviteButton"
}}</gl-button> :disabled="!newUsersToInvite"
variant="success"
@click="sendInvite"
>{{ $options.labels.inviteButtonText }}</gl-button
>
</div> </div>
</template> </template>
</gl-modal> </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 "" ...@@ -14729,6 +14729,9 @@ msgstr ""
msgid "InviteMembersModal|Choose a role permission" msgid "InviteMembersModal|Choose a role permission"
msgstr "" msgstr ""
msgid "InviteMembersModal|Close invite team members"
msgstr ""
msgid "InviteMembersModal|GitLab member or Email address" msgid "InviteMembersModal|GitLab member or Email address"
msgstr "" msgstr ""
...@@ -14738,13 +14741,13 @@ msgstr "" ...@@ -14738,13 +14741,13 @@ msgstr ""
msgid "InviteMembersModal|Invite team members" msgid "InviteMembersModal|Invite team members"
msgstr "" msgstr ""
msgid "InviteMembersModal|Search for members to invite" msgid "InviteMembersModal|Members were successfully added"
msgstr "" msgstr ""
msgid "InviteMembersModal|User not invited. Feature coming soon!" msgid "InviteMembersModal|Search for members to invite"
msgstr "" msgstr ""
msgid "InviteMembersModal|Users were succesfully added" msgid "InviteMembersModal|Some of the members could not be added"
msgstr "" msgstr ""
msgid "InviteMembersModal|You're inviting members to the %{group_name} group" 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 ...@@ -9,7 +9,7 @@ const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, O
const defaultAccessLevel = '10'; const defaultAccessLevel = '10';
const helpLink = 'https://example.com'; const helpLink = 'https://example.com';
const createComponent = () => { const createComponent = (data = {}) => {
return shallowMount(InviteMembersModal, { return shallowMount(InviteMembersModal, {
propsData: { propsData: {
groupId, groupId,
...@@ -18,9 +18,14 @@ const createComponent = () => { ...@@ -18,9 +18,14 @@ const createComponent = () => {
defaultAccessLevel, defaultAccessLevel,
helpLink, helpLink,
}, },
data() {
return data;
},
stubs: { stubs: {
GlSprintf,
'gl-modal': '<div><slot name="modal-footer"></slot><slot></slot></div>', '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', () => { ...@@ -34,7 +39,7 @@ describe('InviteMembersModal', () => {
}); });
const findDropdown = () => wrapper.find(GlDropdown); const findDropdown = () => wrapper.find(GlDropdown);
const findDropdownItems = () => wrapper.findAll(GlDropdownItem); const findDropdownItems = () => findDropdown().findAll(GlDropdownItem);
const findDatepicker = () => wrapper.find(GlDatepicker); const findDatepicker = () => wrapper.find(GlDatepicker);
const findLink = () => wrapper.find(GlLink); const findLink = () => wrapper.find(GlLink);
const findCancelButton = () => wrapper.find({ ref: 'cancelButton' }); const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
...@@ -88,25 +93,69 @@ describe('InviteMembersModal', () => { ...@@ -88,25 +93,69 @@ describe('InviteMembersModal', () => {
format: 'json', format: 'json',
}; };
beforeEach(() => { describe('when the invite was sent successfully', () => {
wrapper = createComponent(); 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.submitForm(postData);
wrapper.vm.$toast = { show: jest.fn() }; });
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', () => { describe('when sending the invite for a single member returned an api error', () => {
expect(Api.inviteGroupMember).toHaveBeenCalledWith(groupId, postData); 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', () => { describe('when sending the invite for multiple members returned any error', () => {
const toastMessageSuccessful = 'Users were succesfully added'; 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( expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
toastMessageSuccessful, genericErrorMessage,
wrapper.vm.toastOptions, 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