Commit fb0a732b authored by Jackie Fraser's avatar Jackie Fraser Committed by Mark Florian

Display API errors in invite modal before closing

API errors for single invitee scenarios are
displayed in an error alert inside the modal.
Multiple failures will display the first error.
parent a673de05
<script>
import {
GlFormGroup,
GlModal,
GlDropdown,
GlDropdownItem,
......@@ -12,16 +13,21 @@ import {
import { partition, isString } from 'lodash';
import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import GroupSelect from '~/invite_members/components/group_select.vue';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { s__, sprintf } from '~/locale';
import { INVITE_MEMBERS_IN_COMMENT, GROUP_FILTERS } from '../constants';
import eventHub from '../event_hub';
import {
responseMessageFromError,
responseMessageFromSuccess,
} from '../utils/response_message_parser';
import GroupSelect from './group_select.vue';
import MembersTokenSelect from './members_token_select.vue';
export default {
name: 'InviteMembersModal',
components: {
GlFormGroup,
GlDatepicker,
GlLink,
GlModal,
......@@ -79,9 +85,13 @@ export default {
selectedDate: undefined,
groupToBeSharedWith: {},
source: 'unknown',
invalidFeedbackMessage: '',
};
},
computed: {
validationState() {
return this.invalidFeedbackMessage === '' ? null : false;
},
isInviteGroup() {
return this.inviteeType === 'group';
},
......@@ -142,6 +152,7 @@ export default {
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
closeModal() {
this.resetFields();
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
},
sendInvite() {
......@@ -150,7 +161,6 @@ export default {
} else {
this.submitInviteMembers();
}
this.closeModal();
},
trackInvite() {
if (this.source === INVITE_MEMBERS_IN_COMMENT) {
......@@ -158,12 +168,12 @@ export default {
tracking.event('comment_invite_success');
}
},
cancelInvite() {
resetFields() {
this.selectedAccessLevel = this.defaultAccessLevel;
this.selectedDate = undefined;
this.newUsersToInvite = [];
this.groupToBeSharedWith = {};
this.closeModal();
this.invalidFeedbackMessage = '';
},
changeSelectedItem(item) {
this.selectedAccessLevel = item;
......@@ -175,9 +185,11 @@ export default {
apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id))
.then(this.showToastMessageSuccess)
.catch(this.showToastMessageError);
.catch(this.showInvalidFeedbackMessage);
},
submitInviteMembers() {
this.invalidFeedbackMessage = '';
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
const promises = [];
......@@ -196,10 +208,11 @@ export default {
promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById)));
}
this.trackInvite();
Promise.all(promises).then(this.showToastMessageSuccess).catch(this.showToastMessageError);
Promise.all(promises)
.then(this.conditionallyShowToastSuccess)
.catch(this.showInvalidFeedbackMessage);
},
inviteByEmailPostData(usersToInviteByEmail) {
return {
......@@ -224,13 +237,27 @@ export default {
group_access: this.selectedAccessLevel,
};
},
conditionallyShowToastSuccess(response) {
const message = responseMessageFromSuccess(response);
if (message === '') {
this.showToastMessageSuccess();
return;
}
this.invalidFeedbackMessage = message;
},
showToastMessageSuccess() {
this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
this.closeModal();
},
showToastMessageError(error) {
const message = error.response.data.message || this.$options.labels.toastMessageUnsuccessful;
this.$toast.show(message, this.toastOptions);
showInvalidFeedbackMessage(response) {
this.invalidFeedbackMessage =
responseMessageFromError(response) || this.$options.labels.invalidFeedbackMessageDefault;
},
handleMembersTokenSelectClear() {
this.invalidFeedbackMessage = '';
},
},
labels: {
......@@ -267,8 +294,8 @@ export default {
accessLevel: s__('InviteMembersModal|Select a role'),
accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'),
toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'),
toastMessageUnsuccessful: s__('InviteMembersModal|Some of the members could not be added'),
readMoreText: s__(`InviteMembersModal|%{linkStart}Learn more%{linkEnd} about roles.`),
invalidFeedbackMessageDefault: s__('InviteMembersModal|Something went wrong'),
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'),
......@@ -283,6 +310,7 @@ export default {
data-qa-selector="invite_members_modal_content"
:title="$options.labels[inviteeType].modalTitle"
:header-close-label="$options.labels.headerCloseLabel"
@close="resetFields"
>
<div>
<p ref="introText">
......@@ -293,15 +321,22 @@ export default {
</gl-sprintf>
</p>
<label :id="$options.membersTokenSelectLabelId" class="gl-font-weight-bold gl-mt-5">{{
<gl-form-group
class="gl-mt-2"
:invalid-feedback="invalidFeedbackMessage"
:state="validationState"
:description="$options.labels[inviteeType].placeHolder"
data-testid="members-form-group"
>
<label :id="$options.membersTokenSelectLabelId" class="col-form-label">{{
$options.labels[inviteeType].searchField
}}</label>
<div class="gl-mt-2">
<members-token-select
v-if="!isInviteGroup"
v-model="newUsersToInvite"
:validation-state="validationState"
:aria-labelledby="$options.membersTokenSelectLabelId"
:placeholder="$options.labels[inviteeType].placeHolder"
@clear="handleMembersTokenSelectClear"
/>
<group-select
v-if="isInviteGroup"
......@@ -309,7 +344,7 @@ export default {
:groups-filter="groupSelectFilter"
:parent-group-id="groupSelectParentId"
/>
</div>
</gl-form-group>
<label class="gl-font-weight-bold gl-mt-3">{{ $options.labels.accessLevel }}</label>
<div class="gl-mt-2 gl-w-half gl-xs-w-full">
......@@ -364,15 +399,15 @@ export default {
<template #modal-footer>
<div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0">
<gl-button ref="cancelButton" @click="cancelInvite">
<gl-button data-testid="cancel-button" @click="closeModal">
{{ $options.labels.cancelButtonText }}
</gl-button>
<div class="gl-mr-3"></div>
<gl-button
ref="inviteButton"
:disabled="inviteDisabled"
variant="success"
data-qa-selector="invite_button"
data-testid="invite-button"
@click="sendInvite"
>{{ $options.labels.inviteButtonText }}</gl-button
>
......
<script>
import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlSprintf } from '@gitlab/ui';
import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlIcon, GlSprintf } from '@gitlab/ui';
import { debounce } from 'lodash';
import { __ } from '~/locale';
import { getUsers } from '~/rest_api';
......@@ -10,6 +10,7 @@ export default {
GlTokenSelector,
GlAvatar,
GlAvatarLabeled,
GlIcon,
GlSprintf,
},
props: {
......@@ -22,6 +23,11 @@ export default {
type: String,
required: true,
},
validationState: {
type: Boolean,
required: false,
default: null,
},
},
data() {
return {
......@@ -84,6 +90,13 @@ export default {
this.hasBeenFocused = true;
},
handleTokenRemove() {
if (this.selectedTokens.length) {
return;
}
this.$emit('clear');
},
},
queryOptions: { exclude_internal: true, active: true },
i18n: {
......@@ -95,19 +108,26 @@ export default {
<template>
<gl-token-selector
v-model="selectedTokens"
:state="validationState"
:dropdown-items="users"
:loading="loading"
:allow-user-defined-tokens="emailIsValid"
:hide-dropdown-with-no-items="hideDropdownWithNoItems"
:placeholder="placeholderText"
:aria-labelledby="ariaLabelledby"
:text-input-attrs="{
'data-testid': 'members-token-select-input',
'data-qa-selector': 'members_token_select_input',
}"
@blur="handleBlur"
@text-input="handleTextInput"
@input="handleInput"
@focus="handleFocus"
@token-remove="handleTokenRemove"
>
<template #token-content="{ token }">
<gl-avatar v-if="token.avatar_url" :src="token.avatar_url" :size="16" />
<gl-icon v-if="validationState === false" name="error" :size="16" class="gl-mr-2" />
<gl-avatar v-else-if="token.avatar_url" :src="token.avatar_url" :size="16" />
{{ token.name }}
</template>
......
import { __ } from '~/locale';
export const SEARCH_DELAY = 200;
export const INVITE_MEMBERS_IN_COMMENT = 'invite_members_in_comment';
......@@ -6,3 +8,7 @@ export const GROUP_FILTERS = {
ALL: 'all',
DESCENDANT_GROUPS: 'descendant_groups',
};
export const API_MESSAGES = {
EMAIL_ALREADY_INVITED: __('Invite email has already been taken'),
};
import { isString } from 'lodash';
import { API_MESSAGES } from '~/invite_members/constants';
function responseKeyedMessageParsed(keyedMessage) {
try {
const keys = Object.keys(keyedMessage);
const msg = keyedMessage[keys[0]];
if (msg === API_MESSAGES.EMAIL_ALREADY_INVITED) {
return '';
}
return msg;
} catch {
return '';
}
}
function responseMessageStringForMultiple(message) {
return message.includes(':');
}
function responseMessageStringFirstPart(message) {
return message.split(' and ')[0];
}
export function responseMessageFromError(response) {
if (!response?.response?.data) {
return '';
}
const {
response: { data },
} = response;
return (
data.error ||
data.message?.user?.[0] ||
data.message?.access_level?.[0] ||
data.message?.error ||
data.message ||
''
);
}
export function responseMessageFromSuccess(response) {
if (!response?.[0]?.data) {
return '';
}
const { data } = response[0];
if (data.message && !data.message.user) {
const { message } = data;
if (isString(message)) {
if (responseMessageStringForMultiple(message)) {
return responseMessageStringFirstPart(message);
}
return message;
}
return responseKeyedMessageParsed(message);
}
return data.message || data.message?.user || data.error || '';
}
......@@ -71,14 +71,14 @@ RSpec.describe 'Groups > Members > List members' do
page.within '#invite-members-modal' do
[user1, user2].each do |user_with_saml|
fill_in 'Select members or type email addresses', with: user_with_saml.name
find('[data-testid="members-token-select-input"]').set(user_with_saml.name)
wait_for_requests
expect(page).to have_content(user_with_saml.name)
end
[user3, user4].each do |user_without_saml|
fill_in 'Select members or type email addresses', with: user_without_saml.name
find('[data-testid="members-token-select-input"]').set(user_without_saml.name)
wait_for_requests
expect(page).not_to have_content(user_without_saml.name)
......
......@@ -17905,6 +17905,9 @@ msgstr ""
msgid "Invite a group"
msgstr ""
msgid "Invite email has already been taken"
msgstr ""
msgid "Invite group"
msgstr ""
......@@ -17956,7 +17959,7 @@ msgstr ""
msgid "InviteMembersBanner|We noticed that you haven't invited anyone to this group. Invite your colleagues so you can discuss issues, collaborate on merge requests, and share your knowledge."
msgstr ""
msgid "InviteMembersModal|%{linkStart}Learn more%{linkEnd} about roles."
msgid "InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions"
msgstr ""
msgid "InviteMembersModal|Access expiration date (optional)"
......@@ -17995,7 +17998,7 @@ msgstr ""
msgid "InviteMembersModal|Select members or type email addresses"
msgstr ""
msgid "InviteMembersModal|Some of the members could not be added"
msgid "InviteMembersModal|Something went wrong"
msgstr ""
msgid "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} group."
......
......@@ -19,6 +19,10 @@ module QA
element :group_select_dropdown_search_field
end
base.view 'app/assets/javascripts/invite_members/components/members_token_select.vue' do
element :members_token_select_input
end
base.view 'app/assets/javascripts/invite_members/components/invite_group_trigger.vue' do
element :invite_a_group_button
end
......@@ -42,7 +46,7 @@ module QA
within_element(:invite_members_modal_content) do
fill_element :access_level_dropdown, with: access_level
fill_in 'Select members or type email addresses', with: username
fill_element :members_token_select_input, username
Support::WaitForRequests.wait_for_requests
......
......@@ -5,7 +5,7 @@ module QA
describe 'Email Notification' do
include Support::Api
let(:user) do
let!(:user) do
Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_1, Runtime::Env.gitlab_qa_password_1)
end
......
......@@ -93,13 +93,13 @@ RSpec.describe 'Groups > Members > Manage members' do
visit group_group_members_path(group)
click_on 'Invite members'
fill_in 'Select members or type email addresses', with: '@gitlab.com'
find('[data-testid="members-token-select-input"]').set('@gitlab.com')
wait_for_requests
expect(page).to have_content('No matches found')
fill_in 'Select members or type email addresses', with: 'undisclosed_email@gitlab.com'
find('[data-testid="members-token-select-input"]').set('undisclosed_email@gitlab.com')
wait_for_requests
expect(page).to have_content("Jane 'invisible' Doe")
......
import { GlDropdown, GlDropdownItem, GlDatepicker, GlSprintf, GlLink, GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import {
GlDropdown,
GlDropdownItem,
GlDatepicker,
GlFormGroup,
GlSprintf,
GlLink,
GlModal,
} from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { stubComponent } from 'helpers/stub_component';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
import { apiPaths, membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses';
let wrapper;
let mock;
jest.mock('~/experimentation/experiment_tracking');
......@@ -26,10 +42,16 @@ const user3 = {
username: 'one_2',
avatar_url: '',
};
const user4 = {
id: 'user-defined-token',
name: 'email4@example.com',
username: 'one_4',
avatar_url: '',
};
const sharedGroup = { id: '981' };
const createComponent = (data = {}, props = {}) => {
return shallowMount(InviteMembersModal, {
wrapper = shallowMountExtended(InviteMembersModal, {
propsData: {
id,
name,
......@@ -51,46 +73,56 @@ const createComponent = (data = {}, props = {}) => {
GlDropdown: true,
GlDropdownItem: true,
GlSprintf,
GlFormGroup: stubComponent(GlFormGroup, {
props: ['state', 'invalidFeedback'],
}),
},
});
};
const createInviteMembersToProjectWrapper = () => {
return createComponent({ inviteeType: 'members' }, { isProject: true });
createComponent({ inviteeType: 'members' }, { isProject: true });
};
const createInviteMembersToGroupWrapper = () => {
return createComponent({ inviteeType: 'members' }, { isProject: false });
createComponent({ inviteeType: 'members' }, { isProject: false });
};
const createInviteGroupToProjectWrapper = () => {
return createComponent({ inviteeType: 'group' }, { isProject: true });
createComponent({ inviteeType: 'group' }, { isProject: true });
};
const createInviteGroupToGroupWrapper = () => {
return createComponent({ inviteeType: 'group' }, { isProject: false });
createComponent({ inviteeType: 'group' }, { isProject: false });
};
describe('InviteMembersModal', () => {
let wrapper;
beforeEach(() => {
gon.api_version = 'v4';
mock = new MockAdapter(axios);
});
afterEach(() => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
mock.restore();
});
describe('InviteMembersModal', () => {
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem);
const findDatepicker = () => wrapper.findComponent(GlDatepicker);
const findLink = () => wrapper.findComponent(GlLink);
const findIntroText = () => wrapper.find({ ref: 'introText' }).text();
const findCancelButton = () => wrapper.findComponent({ ref: 'cancelButton' });
const findInviteButton = () => wrapper.findComponent({ ref: 'inviteButton' });
const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findInviteButton = () => wrapper.findByTestId('invite-button');
const clickInviteButton = () => findInviteButton().vm.$emit('click');
const findMembersFormGroup = () => wrapper.findByTestId('members-form-group');
const membersFormGroupInvalidFeedback = () => findMembersFormGroup().props('invalidFeedback');
const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect);
describe('rendering the modal', () => {
beforeEach(() => {
wrapper = createComponent();
createComponent();
});
it('renders the modal with the correct title', () => {
......@@ -132,7 +164,7 @@ describe('InviteMembersModal', () => {
describe('when inviting to a project', () => {
describe('when inviting members', () => {
it('includes the correct invitee, type, and formatted name', () => {
wrapper = createInviteMembersToProjectWrapper();
createInviteMembersToProjectWrapper();
expect(findIntroText()).toBe("You're inviting members to the test name project.");
});
......@@ -140,7 +172,7 @@ describe('InviteMembersModal', () => {
describe('when sharing with a group', () => {
it('includes the correct invitee, type, and formatted name', () => {
wrapper = createInviteGroupToProjectWrapper();
createInviteGroupToProjectWrapper();
expect(findIntroText()).toBe("You're inviting a group to the test name project.");
});
......@@ -150,7 +182,7 @@ describe('InviteMembersModal', () => {
describe('when inviting to a group', () => {
describe('when inviting members', () => {
it('includes the correct invitee, type, and formatted name', () => {
wrapper = createInviteMembersToGroupWrapper();
createInviteMembersToGroupWrapper();
expect(findIntroText()).toBe("You're inviting members to the test name group.");
});
......@@ -158,7 +190,7 @@ describe('InviteMembersModal', () => {
describe('when sharing with a group', () => {
it('includes the correct invitee, type, and formatted name', () => {
wrapper = createInviteGroupToGroupWrapper();
createInviteGroupToGroupWrapper();
expect(findIntroText()).toBe("You're inviting a group to the test name group.");
});
......@@ -167,22 +199,30 @@ describe('InviteMembersModal', () => {
});
describe('submitting the invite form', () => {
const apiErrorMessage = 'Member already exists';
const mockMembersApi = (code, data) => {
mock.onPost(apiPaths.GROUPS_MEMBERS).reply(code, data);
};
const mockInvitationsApi = (code, data) => {
mock.onPost(apiPaths.GROUPS_INVITATIONS).reply(code, data);
};
const expectedEmailRestrictedError =
"email 'email@example.com' does not match the allowed domains: example1.org";
const expectedSyntaxError = 'email contains an invalid email address';
describe('when inviting an existing user to group by user ID', () => {
const postData = {
user_id: '1',
user_id: '1,2',
access_level: defaultAccessLevel,
expires_at: undefined,
invite_source: inviteSource,
format: 'json',
};
describe('when invites are sent successfully', () => {
describe('when member is added successfully', () => {
beforeEach(() => {
wrapper = createInviteMembersToGroupWrapper();
createComponent({ newUsersToInvite: [user1, user2] });
wrapper.setData({ newUsersToInvite: [user1] });
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
......@@ -190,54 +230,102 @@ describe('InviteMembersModal', () => {
clickInviteButton();
});
it('calls Api addGroupMembersByUserId with the correct params', () => {
it('calls Api addGroupMembersByUserId with the correct params', async () => {
await waitForPromises;
expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, postData);
});
it('displays the successful toastMessage', () => {
it('displays the successful toastMessage', async () => {
await waitForPromises;
expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled();
});
});
describe('when the invite received an api error message', () => {
describe('when member is not added successfully', () => {
beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: [user1] });
createInviteMembersToGroupWrapper();
wrapper.vm.$toast = { show: jest.fn() };
jest
.spyOn(Api, 'addGroupMembersByUserId')
.mockRejectedValue({ response: { data: { message: apiErrorMessage } } });
jest.spyOn(wrapper.vm, 'showToastMessageError');
wrapper.setData({ newUsersToInvite: [user1] });
});
it('displays "Member already exists" api message for http status conflict', async () => {
mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS);
clickInviteButton();
});
it('displays the apiErrorMessage in the toastMessage', async () => {
await waitForPromises();
expect(wrapper.vm.showToastMessageError).toHaveBeenCalledWith({
response: { data: { message: apiErrorMessage } },
expect(membersFormGroupInvalidFeedback()).toBe('Member already exists');
expect(findMembersFormGroup().props('state')).toBe(false);
expect(findMembersSelect().props('validationState')).toBe(false);
});
it('clears the invalid state and message once the list of members to invite is cleared', async () => {
mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS);
clickInviteButton();
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe('Member already exists');
expect(findMembersFormGroup().props('state')).toBe(false);
expect(findMembersSelect().props('validationState')).toBe(false);
findMembersSelect().vm.$emit('clear');
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe('');
expect(findMembersFormGroup().props('state')).not.toBe(false);
expect(findMembersSelect().props('validationState')).not.toBe(false);
});
it('displays the generic error for http server error', async () => {
mockMembersApi(httpStatus.INTERNAL_SERVER_ERROR, 'Request failed with status code 500');
clickInviteButton();
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong');
});
describe('when any invite failed for any other reason', () => {
beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: [user1, user2] });
it('displays the restricted user api message for response with bad request', async () => {
mockMembersApi(httpStatus.BAD_REQUEST, membersApiResponse.SINGLE_USER_RESTRICTED);
wrapper.vm.$toast = { show: jest.fn() };
jest
.spyOn(Api, 'addGroupMembersByUserId')
.mockRejectedValue({ response: { data: { success: false } } });
jest.spyOn(wrapper.vm, 'showToastMessageError');
clickInviteButton();
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe(expectedEmailRestrictedError);
});
it('displays the first part of the error when multiple existing users are restricted by email', async () => {
mockMembersApi(httpStatus.CREATED, membersApiResponse.MULTIPLE_USERS_RESTRICTED);
clickInviteButton();
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe(
"root: User email 'admin@example.com' does not match the allowed domain of example2.com",
);
expect(findMembersSelect().props('validationState')).toBe(false);
});
it('displays the generic error toastMessage', async () => {
it('displays an access_level error message received for the existing user', async () => {
mockMembersApi(httpStatus.BAD_REQUEST, membersApiResponse.SINGLE_USER_ACCESS_LEVEL);
clickInviteButton();
await waitForPromises();
expect(wrapper.vm.showToastMessageError).toHaveBeenCalled();
expect(membersFormGroupInvalidFeedback()).toBe(
'should be greater than or equal to Owner inherited membership from group Gitlab Org',
);
expect(findMembersSelect().props('validationState')).toBe(false);
});
});
});
......@@ -253,7 +341,7 @@ describe('InviteMembersModal', () => {
describe('when invites are sent successfully', () => {
beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: [user3] });
createComponent({ newUsersToInvite: [user3] });
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
......@@ -271,23 +359,84 @@ describe('InviteMembersModal', () => {
});
});
describe('when any invite failed for any reason', () => {
describe('when invites are not sent successfully', () => {
beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: [user1, user2] });
createInviteMembersToGroupWrapper();
wrapper.setData({ newUsersToInvite: [user3] });
});
it('displays the api error for invalid email syntax', async () => {
mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
clickInviteButton();
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
expect(findMembersSelect().props('validationState')).toBe(false);
});
it('displays the restricted email error when restricted email is invited', async () => {
mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_RESTRICTED);
clickInviteButton();
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toContain(expectedEmailRestrictedError);
expect(findMembersSelect().props('validationState')).toBe(false);
});
it('displays the successful toast message when email has already been invited', async () => {
mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN);
wrapper.vm.$toast = { show: jest.fn() };
jest
.spyOn(Api, 'addGroupMembersByUserId')
.mockRejectedValue({ response: { data: { success: false } } });
jest.spyOn(wrapper.vm, 'showToastMessageError');
jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
clickInviteButton();
await waitForPromises();
expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled();
expect(findMembersSelect().props('validationState')).toBe(null);
});
it('displays the first error message when multiple emails return a restricted error message', async () => {
mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_EMAIL_RESTRICTED);
clickInviteButton();
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toContain(expectedEmailRestrictedError);
expect(findMembersSelect().props('validationState')).toBe(false);
});
it('displays the invalid syntax error for bad request', async () => {
mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.ERROR_EMAIL_INVALID);
clickInviteButton();
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
expect(findMembersSelect().props('validationState')).toBe(false);
});
});
describe('when multiple emails are invited at the same time', () => {
it('displays the invalid syntax error if one of the emails is invalid', async () => {
createInviteMembersToGroupWrapper();
wrapper.setData({ newUsersToInvite: [user3, user4] });
mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.ERROR_EMAIL_INVALID);
clickInviteButton();
it('displays the generic error toastMessage', async () => {
await waitForPromises();
expect(wrapper.vm.showToastMessageError).toHaveBeenCalled();
expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
expect(findMembersSelect().props('validationState')).toBe(false);
});
});
});
......@@ -305,7 +454,7 @@ describe('InviteMembersModal', () => {
describe('when invites are sent successfully', () => {
beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: [user1, user3] });
createComponent({ newUsersToInvite: [user1, user3] });
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
......@@ -350,24 +499,20 @@ describe('InviteMembersModal', () => {
describe('when any invite failed for any reason', () => {
beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: [user1, user3] });
createInviteMembersToGroupWrapper();
wrapper.vm.$toast = { show: jest.fn() };
jest
.spyOn(Api, 'inviteGroupMembersByEmail')
.mockRejectedValue({ response: { data: { success: false } } });
wrapper.setData({ newUsersToInvite: [user1, user3] });
jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
jest.spyOn(wrapper.vm, 'showToastMessageError');
mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
mockMembersApi(httpStatus.OK, '200 OK');
clickInviteButton();
});
it('displays the generic error toastMessage', async () => {
it('displays the first error message', async () => {
await waitForPromises();
expect(wrapper.vm.showToastMessageError).toHaveBeenCalled();
expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
});
});
});
......@@ -382,7 +527,7 @@ describe('InviteMembersModal', () => {
};
beforeEach(() => {
wrapper = createComponent({ groupToBeSharedWith: sharedGroup });
createComponent({ groupToBeSharedWith: sharedGroup });
wrapper.setData({ inviteeType: 'group' });
wrapper.vm.$toast = { show: jest.fn() };
......@@ -403,7 +548,7 @@ describe('InviteMembersModal', () => {
describe('when sharing the group fails', () => {
beforeEach(() => {
wrapper = createComponent({ groupToBeSharedWith: sharedGroup });
createComponent({ groupToBeSharedWith: sharedGroup });
wrapper.setData({ inviteeType: 'group' });
wrapper.vm.$toast = { show: jest.fn() };
......@@ -412,22 +557,20 @@ describe('InviteMembersModal', () => {
.spyOn(Api, 'groupShareWithGroup')
.mockRejectedValue({ response: { data: { success: false } } });
jest.spyOn(wrapper.vm, 'showToastMessageError');
clickInviteButton();
});
it('displays the generic error toastMessage', async () => {
it('displays the generic error message', async () => {
await waitForPromises();
expect(wrapper.vm.showToastMessageError).toHaveBeenCalled();
expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong');
});
});
});
describe('tracking', () => {
beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: [user3] });
createComponent({ newUsersToInvite: [user3] });
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({});
......
......@@ -115,6 +115,21 @@ describe('MembersTokenSelect', () => {
expect(wrapper.emitted().input[0][0]).toEqual([user1, user2]);
});
});
describe('when user is removed', () => {
it('emits `clear` event', () => {
findTokenSelector().vm.$emit('token-remove', [user1]);
expect(wrapper.emitted('clear')).toEqual([[]]);
});
it('does not emit `clear` event when there are still tokens selected', () => {
findTokenSelector().vm.$emit('input', [user1, user2]);
findTokenSelector().vm.$emit('token-remove', [user1]);
expect(wrapper.emitted('clear')).toBeUndefined();
});
});
});
describe('when text input is blurred', () => {
......
const INVITATIONS_API_EMAIL_INVALID = {
message: { error: 'email contains an invalid email address' },
};
const INVITATIONS_API_ERROR_EMAIL_INVALID = {
error: 'email contains an invalid email address',
};
const INVITATIONS_API_EMAIL_RESTRICTED = {
message: {
'email@example.com':
"Invite email 'email@example.com' does not match the allowed domains: example1.org",
},
status: 'error',
};
const INVITATIONS_API_MULTIPLE_EMAIL_RESTRICTED = {
message: {
'email@example.com':
"Invite email email 'email@example.com' does not match the allowed domains: example1.org",
'email4@example.com':
"Invite email email 'email4@example.com' does not match the allowed domains: example1.org",
},
status: 'error',
};
const INVITATIONS_API_EMAIL_TAKEN = {
message: {
'email@example2.com': 'Invite email has already been taken',
},
status: 'error',
};
const MEMBERS_API_MEMBER_ALREADY_EXISTS = {
message: 'Member already exists',
};
const MEMBERS_API_SINGLE_USER_RESTRICTED = {
message: { user: ["email 'email@example.com' does not match the allowed domains: example1.org"] },
};
const MEMBERS_API_SINGLE_USER_ACCESS_LEVEL = {
message: {
access_level: [
'should be greater than or equal to Owner inherited membership from group Gitlab Org',
],
},
};
const MEMBERS_API_MULTIPLE_USERS_RESTRICTED = {
message:
"root: User email 'admin@example.com' does not match the allowed domain of example2.com and user18: User email 'user18@example.org' does not match the allowed domain of example2.com",
status: 'error',
};
export const apiPaths = {
GROUPS_MEMBERS: '/api/v4/groups/1/members',
GROUPS_INVITATIONS: '/api/v4/groups/1/invitations',
};
export const membersApiResponse = {
MEMBER_ALREADY_EXISTS: MEMBERS_API_MEMBER_ALREADY_EXISTS,
SINGLE_USER_ACCESS_LEVEL: MEMBERS_API_SINGLE_USER_ACCESS_LEVEL,
SINGLE_USER_RESTRICTED: MEMBERS_API_SINGLE_USER_RESTRICTED,
MULTIPLE_USERS_RESTRICTED: MEMBERS_API_MULTIPLE_USERS_RESTRICTED,
};
export const invitationsApiResponse = {
EMAIL_INVALID: INVITATIONS_API_EMAIL_INVALID,
ERROR_EMAIL_INVALID: INVITATIONS_API_ERROR_EMAIL_INVALID,
EMAIL_RESTRICTED: INVITATIONS_API_EMAIL_RESTRICTED,
MULTIPLE_EMAIL_RESTRICTED: INVITATIONS_API_MULTIPLE_EMAIL_RESTRICTED,
EMAIL_TAKEN: INVITATIONS_API_EMAIL_TAKEN,
};
import {
responseMessageFromSuccess,
responseMessageFromError,
} from '~/invite_members/utils/response_message_parser';
describe('Response message parser', () => {
const expectedMessage = 'expected display message';
describe('parse message from successful response', () => {
const exampleKeyedMsg = { 'email@example.com': expectedMessage };
const exampleUserMsgMultiple =
' and username1: id not found and username2: email is restricted';
it.each([
[[{ data: { message: expectedMessage } }]],
[[{ data: { message: expectedMessage + exampleUserMsgMultiple } }]],
[[{ data: { error: expectedMessage } }]],
[[{ data: { message: [expectedMessage] } }]],
[[{ data: { message: exampleKeyedMsg } }]],
])(`returns "${expectedMessage}" from success response: %j`, (successResponse) => {
expect(responseMessageFromSuccess(successResponse)).toBe(expectedMessage);
});
});
describe('message from error response', () => {
it.each([
[{ response: { data: { error: expectedMessage } } }],
[{ response: { data: { message: { user: [expectedMessage] } } } }],
[{ response: { data: { message: { access_level: [expectedMessage] } } } }],
[{ response: { data: { message: { error: expectedMessage } } } }],
[{ response: { data: { message: expectedMessage } } }],
])(`returns "${expectedMessage}" from error response: %j`, (errorResponse) => {
expect(responseMessageFromError(errorResponse)).toBe(expectedMessage);
});
});
});
......@@ -9,7 +9,7 @@ module Spec
click_on 'Invite members'
page.within '#invite-members-modal' do
fill_in 'Select members or type email addresses', with: name
find('[data-testid="members-token-select-input"]').set(name)
wait_for_requests
click_button name
......
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