Commit 91b16296 authored by Mark Florian's avatar Mark Florian

Merge branch '325147-add-error-alerts-for-invite-members-modal-errors' into 'master'

Display API errors in invite modal before closing [RUN ALL RSPEC]

See merge request gitlab-org/gitlab!56957
parents 10813d3b fb0a732b
<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">{{
$options.labels[inviteeType].searchField
}}</label>
<div class="gl-mt-2">
<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>
<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)
......
......@@ -17923,6 +17923,9 @@ msgstr ""
msgid "Invite a group"
msgstr ""
msgid "Invite email has already been taken"
msgstr ""
msgid "Invite group"
msgstr ""
......@@ -17974,7 +17977,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)"
......@@ -18013,7 +18016,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(() => {
wrapper.destroy();
wrapper = null;
});
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();
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe('Member already exists');
expect(findMembersFormGroup().props('state')).toBe(false);
expect(findMembersSelect().props('validationState')).toBe(false);
});
it('displays the apiErrorMessage in the toastMessage', async () => {
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(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);
findMembersSelect().vm.$emit('clear');
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe('');
expect(findMembersFormGroup().props('state')).not.toBe(false);
expect(findMembersSelect().props('validationState')).not.toBe(false);
});
});
describe('when any invite failed for any other reason', () => {
beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: [user1, user2] });
it('displays the generic error for http server error', async () => {
mockMembersApi(httpStatus.INTERNAL_SERVER_ERROR, 'Request failed with status code 500');
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('Something went wrong');
});
it('displays the restricted user api message for response with bad request', async () => {
mockMembersApi(httpStatus.BAD_REQUEST, membersApiResponse.SINGLE_USER_RESTRICTED);
clickInviteButton();
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe(expectedEmailRestrictedError);
});
it('displays the generic error toastMessage', async () => {
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(wrapper.vm.showToastMessageError).toHaveBeenCalled();
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 an access_level error message received for the existing user', async () => {
mockMembersApi(httpStatus.BAD_REQUEST, membersApiResponse.SINGLE_USER_ACCESS_LEVEL);
clickInviteButton();
await waitForPromises();
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