diff options
Diffstat (limited to 'app/assets/javascripts/invite_members')
12 files changed, 239 insertions, 126 deletions
diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue index fc14b2eba6a..e7f5211dc25 100644 --- a/app/assets/javascripts/invite_members/components/group_select.vue +++ b/app/assets/javascripts/invite_members/components/group_select.vue @@ -136,6 +136,7 @@ export default { v-for="group in groups" :key="group.id" :name="group.name" + data-qa-selector="group_select_dropdown_item" @click="selectGroup(group)" > <gl-avatar-labeled diff --git a/app/assets/javascripts/invite_members/components/import_a_project_modal.vue b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue index fb6c376cfe6..31b7fd4cc42 100644 --- a/app/assets/javascripts/invite_members/components/import_a_project_modal.vue +++ b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue @@ -1,21 +1,20 @@ <script> -import { GlButton, GlFormGroup, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui'; +import { GlFormGroup, GlModal, GlSprintf } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { importProjectMembers } from '~/api/projects_api'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { s__, __, sprintf } from '~/locale'; +import eventHub from '../event_hub'; import ProjectSelect from './project_select.vue'; export default { + name: 'ImportProjectMembersModal', components: { - GlButton, GlFormGroup, GlModal, GlSprintf, ProjectSelect, }, - directives: { - GlModal: GlModalDirective, - }, props: { projectId: { type: String, @@ -45,8 +44,33 @@ export default { validationState() { return this.invalidFeedbackMessage === '' ? null : false; }, + actionPrimary() { + return { + text: this.$options.i18n.modalPrimaryButton, + attributes: { + variant: 'confirm', + disabled: this.importDisabled, + loading: this.isLoading, + }, + }; + }, + actionCancel() { + return { text: this.$options.i18n.modalCancelButton }; + }, + }, + mounted() { + eventHub.$on('openProjectMembersModal', () => { + this.openModal(); + }); }, methods: { + openModal() { + this.$root.$emit(BV_SHOW_MODAL, this.$options.modalId); + }, + resetFields() { + this.invalidFeedbackMessage = ''; + this.projectToBeImported = {}; + }, submitImport() { this.isLoading = true; return importProjectMembers(this.projectId, this.projectToBeImported.id) @@ -57,11 +81,6 @@ export default { this.projectToBeImported = {}; }); }, - closeModal() { - this.invalidFeedbackMessage = ''; - - this.$refs.modal.hide(); - }, showToastMessage() { this.$toast.show(this.$options.i18n.successMessage, this.$options.toastOptions); @@ -79,7 +98,6 @@ export default { }; }, i18n: { - buttonText: s__('ImportAProjectModal|Import from a project'), projectLabel: __('Project'), modalTitle: s__('ImportAProjectModal|Import members from another project'), modalIntro: s__( @@ -95,63 +113,37 @@ export default { }, projectSelectLabelId: 'project-select', modalId: uniqueId('import-a-project-modal-'), - formClasses: 'gl-mt-3 gl-sm-w-auto gl-w-full', - buttonClasses: 'gl-w-full', }; </script> <template> - <form :class="$options.formClasses"> - <gl-button v-gl-modal="$options.modalId" :class="$options.buttonClasses" variant="default">{{ - $options.i18n.buttonText - }}</gl-button> - - <gl-modal - ref="modal" - :modal-id="$options.modalId" - size="sm" - :title="$options.i18n.modalTitle" - ok-variant="danger" - footer-class="gl-bg-gray-10 gl-p-5" + <gl-modal + ref="modal" + :modal-id="$options.modalId" + size="sm" + :title="$options.i18n.modalTitle" + :action-primary="actionPrimary" + :action-cancel="actionCancel" + @primary="submitImport" + @hidden="resetFields" + > + <p ref="modalIntro"> + <gl-sprintf :message="modalIntro"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </p> + <gl-form-group + :invalid-feedback="invalidFeedbackMessage" + :state="validationState" + data-testid="form-group" > - <div> - <p ref="modalIntro"> - <gl-sprintf :message="modalIntro"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </p> - <gl-form-group - :invalid-feedback="invalidFeedbackMessage" - :state="validationState" - data-testid="form-group" - > - <label :id="$options.projectSelectLabelId" class="col-form-label">{{ - $options.i18n.projectLabel - }}</label> - <project-select v-model="projectToBeImported" /> - </gl-form-group> - <p>{{ $options.i18n.modalHelpText }}</p> - </div> - <template #modal-footer> - <div - class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0" - > - <gl-button data-testid="cancel-button" @click="closeModal"> - {{ $options.i18n.modalCancelButton }} - </gl-button> - <div class="gl-mr-3"></div> - <gl-button - :disabled="importDisabled" - :loading="isLoading" - variant="confirm" - data-testid="import-button" - @click="submitImport" - >{{ $options.i18n.modalPrimaryButton }}</gl-button - > - </div> - </template> - </gl-modal> - </form> + <label :id="$options.projectSelectLabelId" class="col-form-label">{{ + $options.i18n.projectLabel + }}</label> + <project-select v-model="projectToBeImported" /> + </gl-form-group> + <p>{{ $options.i18n.modalHelpText }}</p> + </gl-modal> </template> diff --git a/app/assets/javascripts/invite_members/components/import_project_members_trigger.vue b/app/assets/javascripts/invite_members/components/import_project_members_trigger.vue new file mode 100644 index 00000000000..5781abb41b7 --- /dev/null +++ b/app/assets/javascripts/invite_members/components/import_project_members_trigger.vue @@ -0,0 +1,34 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import eventHub from '../event_hub'; + +export default { + components: { + GlButton, + }, + props: { + displayText: { + type: String, + required: false, + default: s__('ImportAProjectModal|Import from a project'), + }, + classes: { + type: String, + required: false, + default: '', + }, + }, + methods: { + openModal() { + eventHub.$emit('openProjectMembersModal'); + }, + }, +}; +</script> + +<template> + <gl-button :class="classes" @click="openModal"> + {{ displayText }} + </gl-button> +</template> diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue index d597c7e53bb..b71cfbb6112 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -7,12 +7,13 @@ import { GlSprintf, GlFormCheckboxGroup, } from '@gitlab/ui'; -import { partition, isString, uniqueId } from 'lodash'; +import { partition, isString, uniqueId, isEmpty } from 'lodash'; import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue'; import Api from '~/api'; import ExperimentTracking from '~/experimentation/experiment_tracking'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { getParameterValues } from '~/lib/utils/url_utility'; +import { n__ } from '~/locale'; import { CLOSE_TO_LIMIT_COUNT, USERS_FILTER_ALL, @@ -21,7 +22,8 @@ import { LEARN_GITLAB, } from '../constants'; import eventHub from '../event_hub'; -import { responseMessageFromSuccess } from '../utils/response_message_parser'; +import { responseFromSuccess } from '../utils/response_message_parser'; +import { memberName } from '../utils/member_utils'; import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message'; import ModalConfetti from './confetti.vue'; import MembersTokenSelect from './members_token_select.vue'; @@ -101,6 +103,7 @@ export default { isLoading: false, modalId: uniqueId('invite-members-modal-'), newUsersToInvite: [], + invalidMembers: {}, selectedTasksToBeDone: [], selectedTaskProject: this.projects[0], source: 'unknown', @@ -125,6 +128,16 @@ export default { inviteDisabled() { return this.newUsersToInvite.length === 0; }, + hasInvalidMembers() { + return !isEmpty(this.invalidMembers); + }, + memberErrorTitle() { + return n__( + "InviteMembersModal|The following member couldn't be invited", + "InviteMembersModal|The following %d members couldn't be invited", + Object.keys(this.invalidMembers).length, + ); + }, tasksToBeDoneEnabled() { return ( (getParameterValues('open_modal')[0] === 'invite_members_for_task' || @@ -218,7 +231,7 @@ export default { }, sendInvite({ accessLevel, expiresAt }) { this.isLoading = true; - this.invalidFeedbackMessage = ''; + this.clearValidation(); const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite(); @@ -242,12 +255,10 @@ export default { ...userId, }) .then((response) => { - const message = responseMessageFromSuccess(response); + const { error, message } = responseFromSuccess(response); - if (message) { - this.showInvalidFeedbackMessage({ - response: { data: { message } }, - }); + if (error) { + this.showMemberErrors(message); } else { this.showSuccessMessage(); } @@ -257,6 +268,13 @@ export default { this.isLoading = false; }); }, + showMemberErrors(message) { + this.invalidMembers = message; + }, + tokenName(username) { + // initial token creation hits this and nothing is found... so safe navigation + return this.newUsersToInvite.find((member) => memberName(member) === username)?.name; + }, trackinviteMembersForTask() { const label = 'selected_tasks_to_be_done'; const property = this.selectedTasksToBeDone.join(','); @@ -264,8 +282,8 @@ export default { tracking.event(INVITE_MEMBERS_FOR_TASK.submit); }, resetFields() { + this.clearValidation(); this.isLoading = false; - this.invalidFeedbackMessage = ''; this.newUsersToInvite = []; this.selectedTasksToBeDone = []; [this.selectedTaskProject] = this.projects; @@ -287,6 +305,11 @@ export default { }, clearValidation() { this.invalidFeedbackMessage = ''; + this.invalidMembers = {}; + }, + removeToken(token) { + delete this.invalidMembers[memberName(token)]; + this.invalidMembers = { ...this.invalidMembers }; }, }, labels: MEMBER_MODAL_LABELS, @@ -324,23 +347,40 @@ export default { <modal-confetti v-if="isCelebration" /> </template> - <template #user-limit-notification> + <template #alert> + <gl-alert + v-if="hasInvalidMembers" + variant="danger" + :dismissible="false" + :title="memberErrorTitle" + data-testid="alert-member-error" + > + {{ $options.labels.memberErrorListText }} + <ul class="gl-pl-5"> + <li v-for="(error, member) in invalidMembers" :key="member"> + <strong>{{ tokenName(member) }}:</strong> {{ error }} + </li> + </ul> + </gl-alert> <user-limit-notification + v-else :close-to-limit="closeToLimit" :reached-limit="reachedLimit" :users-limit-dataset="usersLimitDataset" /> </template> - <template #select="{ validationState, labelId }"> + <template #select="{ exceptionState, labelId }"> <members-token-select v-model="newUsersToInvite" class="gl-mb-2" - :validation-state="validationState" + :exception-state="exceptionState" :aria-labelledby="labelId" :users-filter="usersFilter" :filter-id="filterId" + :invalid-members="invalidMembers" @clear="clearValidation" + @token-remove="removeToken" /> </template> <template #form-after> diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue index 90d266c3155..f917ebc35c2 100644 --- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue +++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue @@ -159,7 +159,7 @@ export default { introText() { return sprintf(this.labelIntroText, { name: this.name }); }, - validationState() { + exceptionState() { return this.invalidFeedbackMessage ? false : null; }, selectLabelId() { @@ -306,11 +306,11 @@ export default { <slot name="intro-text-after"></slot> </div> - <slot name="user-limit-notification"></slot> + <slot name="alert"></slot> <gl-form-group :invalid-feedback="invalidFeedbackMessage" - :state="validationState" + :state="exceptionState" data-testid="members-form-group" > <template #description> @@ -320,7 +320,7 @@ export default { <label :id="selectLabelId" :class="selectLabelClass">{{ labelSearchField }}</label> <gl-form-input v-if="reachedLimit" data-testid="disabled-input" disabled /> - <slot v-else name="select" v-bind="{ validationState, labelId: selectLabelId }"></slot> + <slot v-else name="select" v-bind="{ exceptionState, labelId: selectLabelId }"></slot> </gl-form-group> <template v-if="!reachedLimit"> diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue index 30c9294344e..b2bcb9a5906 100644 --- a/app/assets/javascripts/invite_members/components/members_token_select.vue +++ b/app/assets/javascripts/invite_members/components/members_token_select.vue @@ -3,6 +3,7 @@ import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlIcon, GlSprintf } from '@ import { debounce } from 'lodash'; import { __ } from '~/locale'; import { getUsers } from '~/rest_api'; +import { memberName } from '../utils/member_utils'; import { SEARCH_DELAY, USERS_FILTER_ALL, USERS_FILTER_SAML_PROVIDER_ID } from '../constants'; export default { @@ -23,7 +24,7 @@ export default { type: String, required: true, }, - validationState: { + exceptionState: { type: Boolean, required: false, default: false, @@ -38,6 +39,10 @@ export default { required: false, default: null, }, + invalidMembers: { + type: Object, + required: true, + }, }, data() { return { @@ -109,13 +114,18 @@ export default { this.hasBeenFocused = true; }, - handleTokenRemove() { + handleTokenRemove(value) { if (this.selectedTokens.length) { + this.$emit('token-remove', value); + return; } this.$emit('clear'); }, + hasError(token) { + return Object.keys(this.invalidMembers).includes(memberName(token)); + }, }, defaultQueryOptions: { without_project_bots: true, active: true }, i18n: { @@ -127,7 +137,7 @@ export default { <template> <gl-token-selector v-model="selectedTokens" - :state="validationState" + :state="exceptionState" :dropdown-items="users" :loading="loading" :allow-user-defined-tokens="emailIsValid" @@ -145,8 +155,19 @@ export default { @token-remove="handleTokenRemove" > <template #token-content="{ token }"> - <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" /> + <gl-icon + v-if="hasError(token)" + name="error" + :size="16" + class="gl-mr-2" + :data-testid="`error-icon-${token.id}`" + /> + <gl-avatar + v-else-if="token.avatar_url" + :src="token.avatar_url" + :size="16" + data-testid="token-avatar" + /> {{ token.name }} </template> diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index beb8f5b5aab..6141e5e9e0b 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -74,6 +74,9 @@ export const INVITE_BUTTON_TEXT_DISABLED = s__('InviteMembersModal|Manage member export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel'); export const CANCEL_BUTTON_TEXT_DISABLED = s__('InviteMembersModal|Explore paid plans'); export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members'); +export const MEMBER_ERROR_LIST_TEXT = s__( + 'InviteMembersModal|Review the invite errors and try again:', +); export const MEMBER_MODAL_LABELS = { modal: { @@ -109,6 +112,7 @@ export const MEMBER_MODAL_LABELS = { title: MEMBERS_TASKS_PROJECTS_TITLE, }, toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL, + memberErrorListText: MEMBER_ERROR_LIST_TEXT, }; export const GROUP_MODAL_LABELS = { diff --git a/app/assets/javascripts/invite_members/init_import_a_project_modal.js b/app/assets/javascripts/invite_members/init_import_a_project_modal.js deleted file mode 100644 index 954347467de..00000000000 --- a/app/assets/javascripts/invite_members/init_import_a_project_modal.js +++ /dev/null @@ -1,23 +0,0 @@ -import Vue from 'vue'; -import ImportAProjectModal from '~/invite_members/components/import_a_project_modal.vue'; - -export default function initImportAProjectModal() { - const el = document.querySelector('.js-import-a-project-modal'); - - if (!el) { - return false; - } - - const { projectId, projectName } = el.dataset; - - return new Vue({ - el, - render: (createElement) => - createElement(ImportAProjectModal, { - props: { - projectId, - projectName, - }, - }), - }); -} diff --git a/app/assets/javascripts/invite_members/init_import_project_members_modal.js b/app/assets/javascripts/invite_members/init_import_project_members_modal.js new file mode 100644 index 00000000000..daaa1315884 --- /dev/null +++ b/app/assets/javascripts/invite_members/init_import_project_members_modal.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import ImportProjectMembersModal from '~/invite_members/components/import_project_members_modal.vue'; + +export default function initImportProjectMembersModal() { + const el = document.querySelector('.js-import-project-members-modal'); + + if (!el) { + return false; + } + + const { projectId, projectName } = el.dataset; + + return new Vue({ + el, + render: (createElement) => + createElement(ImportProjectMembersModal, { + props: { + projectId, + projectName, + }, + }), + }); +} diff --git a/app/assets/javascripts/invite_members/init_import_project_members_trigger.js b/app/assets/javascripts/invite_members/init_import_project_members_trigger.js new file mode 100644 index 00000000000..66a9bf118d2 --- /dev/null +++ b/app/assets/javascripts/invite_members/init_import_project_members_trigger.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import ImportProjectMembersTrigger from '~/invite_members/components/import_project_members_trigger.vue'; + +export default function initImportProjectMembersTrigger() { + const el = document.querySelector('.js-import-project-members-trigger'); + + if (!el) { + return false; + } + + return new Vue({ + el, + render: (createElement) => + createElement(ImportProjectMembersTrigger, { + props: { + ...el.dataset, + }, + }), + }); +} diff --git a/app/assets/javascripts/invite_members/utils/member_utils.js b/app/assets/javascripts/invite_members/utils/member_utils.js new file mode 100644 index 00000000000..d85162626f1 --- /dev/null +++ b/app/assets/javascripts/invite_members/utils/member_utils.js @@ -0,0 +1,4 @@ +export function memberName(member) { + // user defined tokens(invites by email) will have email in `name` and will not contain `username` + return member.username || member.name; +} diff --git a/app/assets/javascripts/invite_members/utils/response_message_parser.js b/app/assets/javascripts/invite_members/utils/response_message_parser.js index db8ac303dc4..6e6431b89d9 100644 --- a/app/assets/javascripts/invite_members/utils/response_message_parser.js +++ b/app/assets/javascripts/invite_members/utils/response_message_parser.js @@ -1,15 +1,4 @@ -import { isString } from 'lodash'; - -function responseKeyedMessageParsed(keyedMessage) { - try { - const keys = Object.keys(keyedMessage); - const msg = keyedMessage[keys[0]]; - - return msg; - } catch { - return ''; - } -} +import { isString, isArray } from 'lodash'; export function responseMessageFromError(response) { if (!response?.response?.data) { @@ -23,9 +12,9 @@ export function responseMessageFromError(response) { return data.error || data.message?.error || data.message || ''; } -export function responseMessageFromSuccess(response) { +export function responseFromSuccess(response) { if (!response?.data) { - return ''; + return { error: false }; } const { data } = response; @@ -34,11 +23,19 @@ export function responseMessageFromSuccess(response) { const { message } = data; if (isString(message)) { - return message; + return { message, error: true }; + } + + if (isArray(message)) { + return { message: message[0], error: true }; } + // we assume object now with our keyed format + return { message: { ...message }, error: true }; + } - return responseKeyedMessageParsed(message); + if (data.error) { + return { message: data.error, error: true }; } - return data.error || ''; + return { error: false }; } |