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">{{
$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)
......
......@@ -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")
......
......@@ -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