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> <script>
import { import {
GlFormGroup,
GlModal, GlModal,
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
...@@ -12,16 +13,21 @@ import { ...@@ -12,16 +13,21 @@ import {
import { partition, isString } from 'lodash'; import { partition, isString } from 'lodash';
import Api from '~/api'; import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking'; 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 { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import { INVITE_MEMBERS_IN_COMMENT, GROUP_FILTERS } from '../constants'; import { INVITE_MEMBERS_IN_COMMENT, GROUP_FILTERS } from '../constants';
import eventHub from '../event_hub'; 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 { export default {
name: 'InviteMembersModal', name: 'InviteMembersModal',
components: { components: {
GlFormGroup,
GlDatepicker, GlDatepicker,
GlLink, GlLink,
GlModal, GlModal,
...@@ -79,9 +85,13 @@ export default { ...@@ -79,9 +85,13 @@ export default {
selectedDate: undefined, selectedDate: undefined,
groupToBeSharedWith: {}, groupToBeSharedWith: {},
source: 'unknown', source: 'unknown',
invalidFeedbackMessage: '',
}; };
}, },
computed: { computed: {
validationState() {
return this.invalidFeedbackMessage === '' ? null : false;
},
isInviteGroup() { isInviteGroup() {
return this.inviteeType === 'group'; return this.inviteeType === 'group';
}, },
...@@ -142,6 +152,7 @@ export default { ...@@ -142,6 +152,7 @@ export default {
this.$root.$emit(BV_SHOW_MODAL, this.modalId); this.$root.$emit(BV_SHOW_MODAL, this.modalId);
}, },
closeModal() { closeModal() {
this.resetFields();
this.$root.$emit(BV_HIDE_MODAL, this.modalId); this.$root.$emit(BV_HIDE_MODAL, this.modalId);
}, },
sendInvite() { sendInvite() {
...@@ -150,7 +161,6 @@ export default { ...@@ -150,7 +161,6 @@ export default {
} else { } else {
this.submitInviteMembers(); this.submitInviteMembers();
} }
this.closeModal();
}, },
trackInvite() { trackInvite() {
if (this.source === INVITE_MEMBERS_IN_COMMENT) { if (this.source === INVITE_MEMBERS_IN_COMMENT) {
...@@ -158,12 +168,12 @@ export default { ...@@ -158,12 +168,12 @@ export default {
tracking.event('comment_invite_success'); tracking.event('comment_invite_success');
} }
}, },
cancelInvite() { resetFields() {
this.selectedAccessLevel = this.defaultAccessLevel; this.selectedAccessLevel = this.defaultAccessLevel;
this.selectedDate = undefined; this.selectedDate = undefined;
this.newUsersToInvite = []; this.newUsersToInvite = [];
this.groupToBeSharedWith = {}; this.groupToBeSharedWith = {};
this.closeModal(); this.invalidFeedbackMessage = '';
}, },
changeSelectedItem(item) { changeSelectedItem(item) {
this.selectedAccessLevel = item; this.selectedAccessLevel = item;
...@@ -175,9 +185,11 @@ export default { ...@@ -175,9 +185,11 @@ export default {
apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id)) apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id))
.then(this.showToastMessageSuccess) .then(this.showToastMessageSuccess)
.catch(this.showToastMessageError); .catch(this.showInvalidFeedbackMessage);
}, },
submitInviteMembers() { submitInviteMembers() {
this.invalidFeedbackMessage = '';
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite(); const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
const promises = []; const promises = [];
...@@ -196,10 +208,11 @@ export default { ...@@ -196,10 +208,11 @@ export default {
promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById))); promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById)));
} }
this.trackInvite(); this.trackInvite();
Promise.all(promises).then(this.showToastMessageSuccess).catch(this.showToastMessageError); Promise.all(promises)
.then(this.conditionallyShowToastSuccess)
.catch(this.showInvalidFeedbackMessage);
}, },
inviteByEmailPostData(usersToInviteByEmail) { inviteByEmailPostData(usersToInviteByEmail) {
return { return {
...@@ -224,13 +237,27 @@ export default { ...@@ -224,13 +237,27 @@ export default {
group_access: this.selectedAccessLevel, group_access: this.selectedAccessLevel,
}; };
}, },
conditionallyShowToastSuccess(response) {
const message = responseMessageFromSuccess(response);
if (message === '') {
this.showToastMessageSuccess();
return;
}
this.invalidFeedbackMessage = message;
},
showToastMessageSuccess() { showToastMessageSuccess() {
this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
this.closeModal();
}, },
showToastMessageError(error) { showInvalidFeedbackMessage(response) {
const message = error.response.data.message || this.$options.labels.toastMessageUnsuccessful; this.invalidFeedbackMessage =
responseMessageFromError(response) || this.$options.labels.invalidFeedbackMessageDefault;
this.$toast.show(message, this.toastOptions); },
handleMembersTokenSelectClear() {
this.invalidFeedbackMessage = '';
}, },
}, },
labels: { labels: {
...@@ -267,8 +294,8 @@ export default { ...@@ -267,8 +294,8 @@ export default {
accessLevel: s__('InviteMembersModal|Select a role'), accessLevel: s__('InviteMembersModal|Select a role'),
accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'), accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'),
toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'), toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'),
toastMessageUnsuccessful: s__('InviteMembersModal|Some of the members could not be added'), invalidFeedbackMessageDefault: s__('InviteMembersModal|Something went wrong'),
readMoreText: s__(`InviteMembersModal|%{linkStart}Learn more%{linkEnd} about roles.`), 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'), headerCloseLabel: s__('InviteMembersModal|Close invite team members'),
...@@ -283,6 +310,7 @@ export default { ...@@ -283,6 +310,7 @@ export default {
data-qa-selector="invite_members_modal_content" data-qa-selector="invite_members_modal_content"
:title="$options.labels[inviteeType].modalTitle" :title="$options.labels[inviteeType].modalTitle"
:header-close-label="$options.labels.headerCloseLabel" :header-close-label="$options.labels.headerCloseLabel"
@close="resetFields"
> >
<div> <div>
<p ref="introText"> <p ref="introText">
...@@ -293,15 +321,22 @@ export default { ...@@ -293,15 +321,22 @@ export default {
</gl-sprintf> </gl-sprintf>
</p> </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 $options.labels[inviteeType].searchField
}}</label> }}</label>
<div class="gl-mt-2">
<members-token-select <members-token-select
v-if="!isInviteGroup" v-if="!isInviteGroup"
v-model="newUsersToInvite" v-model="newUsersToInvite"
:validation-state="validationState"
:aria-labelledby="$options.membersTokenSelectLabelId" :aria-labelledby="$options.membersTokenSelectLabelId"
:placeholder="$options.labels[inviteeType].placeHolder" @clear="handleMembersTokenSelectClear"
/> />
<group-select <group-select
v-if="isInviteGroup" v-if="isInviteGroup"
...@@ -309,7 +344,7 @@ export default { ...@@ -309,7 +344,7 @@ export default {
:groups-filter="groupSelectFilter" :groups-filter="groupSelectFilter"
:parent-group-id="groupSelectParentId" :parent-group-id="groupSelectParentId"
/> />
</div> </gl-form-group>
<label class="gl-font-weight-bold gl-mt-3">{{ $options.labels.accessLevel }}</label> <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"> <div class="gl-mt-2 gl-w-half gl-xs-w-full">
...@@ -364,15 +399,15 @@ export default { ...@@ -364,15 +399,15 @@ export default {
<template #modal-footer> <template #modal-footer>
<div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0"> <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 }} {{ $options.labels.cancelButtonText }}
</gl-button> </gl-button>
<div class="gl-mr-3"></div> <div class="gl-mr-3"></div>
<gl-button <gl-button
ref="inviteButton"
:disabled="inviteDisabled" :disabled="inviteDisabled"
variant="success" variant="success"
data-qa-selector="invite_button" data-qa-selector="invite_button"
data-testid="invite-button"
@click="sendInvite" @click="sendInvite"
>{{ $options.labels.inviteButtonText }}</gl-button >{{ $options.labels.inviteButtonText }}</gl-button
> >
......
<script> <script>
import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlSprintf } from '@gitlab/ui'; import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlIcon, GlSprintf } from '@gitlab/ui';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { getUsers } from '~/rest_api'; import { getUsers } from '~/rest_api';
...@@ -10,6 +10,7 @@ export default { ...@@ -10,6 +10,7 @@ export default {
GlTokenSelector, GlTokenSelector,
GlAvatar, GlAvatar,
GlAvatarLabeled, GlAvatarLabeled,
GlIcon,
GlSprintf, GlSprintf,
}, },
props: { props: {
...@@ -22,6 +23,11 @@ export default { ...@@ -22,6 +23,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
validationState: {
type: Boolean,
required: false,
default: null,
},
}, },
data() { data() {
return { return {
...@@ -84,6 +90,13 @@ export default { ...@@ -84,6 +90,13 @@ export default {
this.hasBeenFocused = true; this.hasBeenFocused = true;
}, },
handleTokenRemove() {
if (this.selectedTokens.length) {
return;
}
this.$emit('clear');
},
}, },
queryOptions: { exclude_internal: true, active: true }, queryOptions: { exclude_internal: true, active: true },
i18n: { i18n: {
...@@ -95,19 +108,26 @@ export default { ...@@ -95,19 +108,26 @@ export default {
<template> <template>
<gl-token-selector <gl-token-selector
v-model="selectedTokens" v-model="selectedTokens"
:state="validationState"
:dropdown-items="users" :dropdown-items="users"
:loading="loading" :loading="loading"
:allow-user-defined-tokens="emailIsValid" :allow-user-defined-tokens="emailIsValid"
:hide-dropdown-with-no-items="hideDropdownWithNoItems" :hide-dropdown-with-no-items="hideDropdownWithNoItems"
:placeholder="placeholderText" :placeholder="placeholderText"
:aria-labelledby="ariaLabelledby" :aria-labelledby="ariaLabelledby"
:text-input-attrs="{
'data-testid': 'members-token-select-input',
'data-qa-selector': 'members_token_select_input',
}"
@blur="handleBlur" @blur="handleBlur"
@text-input="handleTextInput" @text-input="handleTextInput"
@input="handleInput" @input="handleInput"
@focus="handleFocus" @focus="handleFocus"
@token-remove="handleTokenRemove"
> >
<template #token-content="{ token }"> <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 }} {{ token.name }}
</template> </template>
......
import { __ } from '~/locale';
export const SEARCH_DELAY = 200; export const SEARCH_DELAY = 200;
export const INVITE_MEMBERS_IN_COMMENT = 'invite_members_in_comment'; export const INVITE_MEMBERS_IN_COMMENT = 'invite_members_in_comment';
...@@ -6,3 +8,7 @@ export const GROUP_FILTERS = { ...@@ -6,3 +8,7 @@ export const GROUP_FILTERS = {
ALL: 'all', ALL: 'all',
DESCENDANT_GROUPS: 'descendant_groups', 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 ...@@ -71,14 +71,14 @@ RSpec.describe 'Groups > Members > List members' do
page.within '#invite-members-modal' do page.within '#invite-members-modal' do
[user1, user2].each do |user_with_saml| [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 wait_for_requests
expect(page).to have_content(user_with_saml.name) expect(page).to have_content(user_with_saml.name)
end end
[user3, user4].each do |user_without_saml| [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 wait_for_requests
expect(page).not_to have_content(user_without_saml.name) expect(page).not_to have_content(user_without_saml.name)
......
...@@ -17905,6 +17905,9 @@ msgstr "" ...@@ -17905,6 +17905,9 @@ msgstr ""
msgid "Invite a group" msgid "Invite a group"
msgstr "" msgstr ""
msgid "Invite email has already been taken"
msgstr ""
msgid "Invite group" msgid "Invite group"
msgstr "" msgstr ""
...@@ -17956,7 +17959,7 @@ 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." 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 "" msgstr ""
msgid "InviteMembersModal|%{linkStart}Learn more%{linkEnd} about roles." msgid "InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions"
msgstr "" msgstr ""
msgid "InviteMembersModal|Access expiration date (optional)" msgid "InviteMembersModal|Access expiration date (optional)"
...@@ -17995,7 +17998,7 @@ msgstr "" ...@@ -17995,7 +17998,7 @@ msgstr ""
msgid "InviteMembersModal|Select members or type email addresses" msgid "InviteMembersModal|Select members or type email addresses"
msgstr "" msgstr ""
msgid "InviteMembersModal|Some of the members could not be added" msgid "InviteMembersModal|Something went wrong"
msgstr "" msgstr ""
msgid "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} group." msgid "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} group."
......
...@@ -19,6 +19,10 @@ module QA ...@@ -19,6 +19,10 @@ module QA
element :group_select_dropdown_search_field element :group_select_dropdown_search_field
end 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 base.view 'app/assets/javascripts/invite_members/components/invite_group_trigger.vue' do
element :invite_a_group_button element :invite_a_group_button
end end
...@@ -42,7 +46,7 @@ module QA ...@@ -42,7 +46,7 @@ module QA
within_element(:invite_members_modal_content) do within_element(:invite_members_modal_content) do
fill_element :access_level_dropdown, with: access_level 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 Support::WaitForRequests.wait_for_requests
......
...@@ -5,7 +5,7 @@ module QA ...@@ -5,7 +5,7 @@ module QA
describe 'Email Notification' do describe 'Email Notification' do
include Support::Api 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) Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_1, Runtime::Env.gitlab_qa_password_1)
end end
......
...@@ -93,13 +93,13 @@ RSpec.describe 'Groups > Members > Manage members' do ...@@ -93,13 +93,13 @@ RSpec.describe 'Groups > Members > Manage members' do
visit group_group_members_path(group) visit group_group_members_path(group)
click_on 'Invite members' 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 wait_for_requests
expect(page).to have_content('No matches found') 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 wait_for_requests
expect(page).to have_content("Jane 'invisible' Doe") expect(page).to have_content("Jane 'invisible' Doe")
......
import { GlDropdown, GlDropdownItem, GlDatepicker, GlSprintf, GlLink, GlModal } from '@gitlab/ui'; import {
import { shallowMount } from '@vue/test-utils'; GlDropdown,
GlDropdownItem,
GlDatepicker,
GlFormGroup,
GlSprintf,
GlLink,
GlModal,
} from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { stubComponent } from 'helpers/stub_component'; import { stubComponent } from 'helpers/stub_component';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import Api from '~/api'; import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking'; import ExperimentTracking from '~/experimentation/experiment_tracking';
import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue'; 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 { 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'); jest.mock('~/experimentation/experiment_tracking');
...@@ -26,10 +42,16 @@ const user3 = { ...@@ -26,10 +42,16 @@ const user3 = {
username: 'one_2', username: 'one_2',
avatar_url: '', avatar_url: '',
}; };
const user4 = {
id: 'user-defined-token',
name: 'email4@example.com',
username: 'one_4',
avatar_url: '',
};
const sharedGroup = { id: '981' }; const sharedGroup = { id: '981' };
const createComponent = (data = {}, props = {}) => { const createComponent = (data = {}, props = {}) => {
return shallowMount(InviteMembersModal, { wrapper = shallowMountExtended(InviteMembersModal, {
propsData: { propsData: {
id, id,
name, name,
...@@ -51,46 +73,56 @@ const createComponent = (data = {}, props = {}) => { ...@@ -51,46 +73,56 @@ const createComponent = (data = {}, props = {}) => {
GlDropdown: true, GlDropdown: true,
GlDropdownItem: true, GlDropdownItem: true,
GlSprintf, GlSprintf,
GlFormGroup: stubComponent(GlFormGroup, {
props: ['state', 'invalidFeedback'],
}),
}, },
}); });
}; };
const createInviteMembersToProjectWrapper = () => { const createInviteMembersToProjectWrapper = () => {
return createComponent({ inviteeType: 'members' }, { isProject: true }); createComponent({ inviteeType: 'members' }, { isProject: true });
}; };
const createInviteMembersToGroupWrapper = () => { const createInviteMembersToGroupWrapper = () => {
return createComponent({ inviteeType: 'members' }, { isProject: false }); createComponent({ inviteeType: 'members' }, { isProject: false });
}; };
const createInviteGroupToProjectWrapper = () => { const createInviteGroupToProjectWrapper = () => {
return createComponent({ inviteeType: 'group' }, { isProject: true }); createComponent({ inviteeType: 'group' }, { isProject: true });
}; };
const createInviteGroupToGroupWrapper = () => { const createInviteGroupToGroupWrapper = () => {
return createComponent({ inviteeType: 'group' }, { isProject: false }); createComponent({ inviteeType: 'group' }, { isProject: false });
}; };
describe('InviteMembersModal', () => { beforeEach(() => {
let wrapper; gon.api_version = 'v4';
mock = new MockAdapter(axios);
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
}); mock.restore();
});
describe('InviteMembersModal', () => {
const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem); const findDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem);
const findDatepicker = () => wrapper.findComponent(GlDatepicker); const findDatepicker = () => wrapper.findComponent(GlDatepicker);
const findLink = () => wrapper.findComponent(GlLink); const findLink = () => wrapper.findComponent(GlLink);
const findIntroText = () => wrapper.find({ ref: 'introText' }).text(); const findIntroText = () => wrapper.find({ ref: 'introText' }).text();
const findCancelButton = () => wrapper.findComponent({ ref: 'cancelButton' }); const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findInviteButton = () => wrapper.findComponent({ ref: 'inviteButton' }); const findInviteButton = () => wrapper.findByTestId('invite-button');
const clickInviteButton = () => findInviteButton().vm.$emit('click'); 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', () => { describe('rendering the modal', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); createComponent();
}); });
it('renders the modal with the correct title', () => { it('renders the modal with the correct title', () => {
...@@ -132,7 +164,7 @@ describe('InviteMembersModal', () => { ...@@ -132,7 +164,7 @@ describe('InviteMembersModal', () => {
describe('when inviting to a project', () => { describe('when inviting to a project', () => {
describe('when inviting members', () => { describe('when inviting members', () => {
it('includes the correct invitee, type, and formatted name', () => { it('includes the correct invitee, type, and formatted name', () => {
wrapper = createInviteMembersToProjectWrapper(); createInviteMembersToProjectWrapper();
expect(findIntroText()).toBe("You're inviting members to the test name project."); expect(findIntroText()).toBe("You're inviting members to the test name project.");
}); });
...@@ -140,7 +172,7 @@ describe('InviteMembersModal', () => { ...@@ -140,7 +172,7 @@ describe('InviteMembersModal', () => {
describe('when sharing with a group', () => { describe('when sharing with a group', () => {
it('includes the correct invitee, type, and formatted name', () => { 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."); expect(findIntroText()).toBe("You're inviting a group to the test name project.");
}); });
...@@ -150,7 +182,7 @@ describe('InviteMembersModal', () => { ...@@ -150,7 +182,7 @@ describe('InviteMembersModal', () => {
describe('when inviting to a group', () => { describe('when inviting to a group', () => {
describe('when inviting members', () => { describe('when inviting members', () => {
it('includes the correct invitee, type, and formatted name', () => { it('includes the correct invitee, type, and formatted name', () => {
wrapper = createInviteMembersToGroupWrapper(); createInviteMembersToGroupWrapper();
expect(findIntroText()).toBe("You're inviting members to the test name group."); expect(findIntroText()).toBe("You're inviting members to the test name group.");
}); });
...@@ -158,7 +190,7 @@ describe('InviteMembersModal', () => { ...@@ -158,7 +190,7 @@ describe('InviteMembersModal', () => {
describe('when sharing with a group', () => { describe('when sharing with a group', () => {
it('includes the correct invitee, type, and formatted name', () => { 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."); expect(findIntroText()).toBe("You're inviting a group to the test name group.");
}); });
...@@ -167,22 +199,30 @@ describe('InviteMembersModal', () => { ...@@ -167,22 +199,30 @@ describe('InviteMembersModal', () => {
}); });
describe('submitting the invite form', () => { 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', () => { describe('when inviting an existing user to group by user ID', () => {
const postData = { const postData = {
user_id: '1', user_id: '1,2',
access_level: defaultAccessLevel, access_level: defaultAccessLevel,
expires_at: undefined, expires_at: undefined,
invite_source: inviteSource, invite_source: inviteSource,
format: 'json', format: 'json',
}; };
describe('when invites are sent successfully', () => { describe('when member is added successfully', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createInviteMembersToGroupWrapper(); createComponent({ newUsersToInvite: [user1, user2] });
wrapper.setData({ newUsersToInvite: [user1] });
wrapper.vm.$toast = { show: jest.fn() }; wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
...@@ -190,54 +230,102 @@ describe('InviteMembersModal', () => { ...@@ -190,54 +230,102 @@ describe('InviteMembersModal', () => {
clickInviteButton(); 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); expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, postData);
}); });
it('displays the successful toastMessage', () => { it('displays the successful toastMessage', async () => {
await waitForPromises;
expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled();
}); });
}); });
describe('when the invite received an api error message', () => { describe('when member is not added successfully', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: [user1] }); createInviteMembersToGroupWrapper();
wrapper.vm.$toast = { show: jest.fn() }; wrapper.setData({ newUsersToInvite: [user1] });
jest });
.spyOn(Api, 'addGroupMembersByUserId')
.mockRejectedValue({ response: { data: { message: apiErrorMessage } } }); it('displays "Member already exists" api message for http status conflict', async () => {
jest.spyOn(wrapper.vm, 'showToastMessageError'); mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS);
clickInviteButton(); clickInviteButton();
});
it('displays the apiErrorMessage in the toastMessage', async () => {
await waitForPromises(); await waitForPromises();
expect(wrapper.vm.showToastMessageError).toHaveBeenCalledWith({ expect(membersFormGroupInvalidFeedback()).toBe('Member already exists');
response: { data: { message: apiErrorMessage } }, 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', () => { it('displays the restricted user api message for response with bad request', async () => {
beforeEach(() => { mockMembersApi(httpStatus.BAD_REQUEST, membersApiResponse.SINGLE_USER_RESTRICTED);
wrapper = createComponent({ newUsersToInvite: [user1, user2] });
wrapper.vm.$toast = { show: jest.fn() }; clickInviteButton();
jest
.spyOn(Api, 'addGroupMembersByUserId') await waitForPromises();
.mockRejectedValue({ response: { data: { success: false } } });
jest.spyOn(wrapper.vm, 'showToastMessageError'); 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(); 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(); 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', () => { ...@@ -253,7 +341,7 @@ describe('InviteMembersModal', () => {
describe('when invites are sent successfully', () => { describe('when invites are sent successfully', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: [user3] }); createComponent({ newUsersToInvite: [user3] });
wrapper.vm.$toast = { show: jest.fn() }; wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
...@@ -271,23 +359,84 @@ describe('InviteMembersModal', () => { ...@@ -271,23 +359,84 @@ describe('InviteMembersModal', () => {
}); });
}); });
describe('when any invite failed for any reason', () => { describe('when invites are not sent successfully', () => {
beforeEach(() => { 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() }; wrapper.vm.$toast = { show: jest.fn() };
jest jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
.spyOn(Api, 'addGroupMembersByUserId')
.mockRejectedValue({ response: { data: { success: false } } });
jest.spyOn(wrapper.vm, 'showToastMessageError');
clickInviteButton(); 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(); await waitForPromises();
expect(wrapper.vm.showToastMessageError).toHaveBeenCalled(); expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
expect(findMembersSelect().props('validationState')).toBe(false);
}); });
}); });
}); });
...@@ -305,7 +454,7 @@ describe('InviteMembersModal', () => { ...@@ -305,7 +454,7 @@ describe('InviteMembersModal', () => {
describe('when invites are sent successfully', () => { describe('when invites are sent successfully', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: [user1, user3] }); createComponent({ newUsersToInvite: [user1, user3] });
wrapper.vm.$toast = { show: jest.fn() }; wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
...@@ -350,24 +499,20 @@ describe('InviteMembersModal', () => { ...@@ -350,24 +499,20 @@ describe('InviteMembersModal', () => {
describe('when any invite failed for any reason', () => { describe('when any invite failed for any reason', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: [user1, user3] }); createInviteMembersToGroupWrapper();
wrapper.vm.$toast = { show: jest.fn() }; wrapper.setData({ newUsersToInvite: [user1, user3] });
jest
.spyOn(Api, 'inviteGroupMembersByEmail')
.mockRejectedValue({ response: { data: { success: false } } });
jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
jest.spyOn(wrapper.vm, 'showToastMessageError'); mockMembersApi(httpStatus.OK, '200 OK');
clickInviteButton(); clickInviteButton();
}); });
it('displays the generic error toastMessage', async () => { it('displays the first error message', async () => {
await waitForPromises(); await waitForPromises();
expect(wrapper.vm.showToastMessageError).toHaveBeenCalled(); expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
}); });
}); });
}); });
...@@ -382,7 +527,7 @@ describe('InviteMembersModal', () => { ...@@ -382,7 +527,7 @@ describe('InviteMembersModal', () => {
}; };
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ groupToBeSharedWith: sharedGroup }); createComponent({ groupToBeSharedWith: sharedGroup });
wrapper.setData({ inviteeType: 'group' }); wrapper.setData({ inviteeType: 'group' });
wrapper.vm.$toast = { show: jest.fn() }; wrapper.vm.$toast = { show: jest.fn() };
...@@ -403,7 +548,7 @@ describe('InviteMembersModal', () => { ...@@ -403,7 +548,7 @@ describe('InviteMembersModal', () => {
describe('when sharing the group fails', () => { describe('when sharing the group fails', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ groupToBeSharedWith: sharedGroup }); createComponent({ groupToBeSharedWith: sharedGroup });
wrapper.setData({ inviteeType: 'group' }); wrapper.setData({ inviteeType: 'group' });
wrapper.vm.$toast = { show: jest.fn() }; wrapper.vm.$toast = { show: jest.fn() };
...@@ -412,22 +557,20 @@ describe('InviteMembersModal', () => { ...@@ -412,22 +557,20 @@ describe('InviteMembersModal', () => {
.spyOn(Api, 'groupShareWithGroup') .spyOn(Api, 'groupShareWithGroup')
.mockRejectedValue({ response: { data: { success: false } } }); .mockRejectedValue({ response: { data: { success: false } } });
jest.spyOn(wrapper.vm, 'showToastMessageError');
clickInviteButton(); clickInviteButton();
}); });
it('displays the generic error toastMessage', async () => { it('displays the generic error message', async () => {
await waitForPromises(); await waitForPromises();
expect(wrapper.vm.showToastMessageError).toHaveBeenCalled(); expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong');
}); });
}); });
}); });
describe('tracking', () => { describe('tracking', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: [user3] }); createComponent({ newUsersToInvite: [user3] });
wrapper.vm.$toast = { show: jest.fn() }; wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({}); jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({});
......
...@@ -115,6 +115,21 @@ describe('MembersTokenSelect', () => { ...@@ -115,6 +115,21 @@ describe('MembersTokenSelect', () => {
expect(wrapper.emitted().input[0][0]).toEqual([user1, user2]); 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', () => { 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 ...@@ -9,7 +9,7 @@ module Spec
click_on 'Invite members' click_on 'Invite members'
page.within '#invite-members-modal' do 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 wait_for_requests
click_button name 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