diff --git a/app/assets/javascripts/invite_members/components/invite_group_trigger.vue b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue index c9de078319a3bfea05f3f53b476ecb8c4d0d0656..c08a4d75c5942d3b86f9da8137e85f0c4fd7a268 100644 --- a/app/assets/javascripts/invite_members/components/invite_group_trigger.vue +++ b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue @@ -21,7 +21,7 @@ export default { }, methods: { openModal() { - eventHub.$emit('openModal', { inviteeType: 'group' }); + eventHub.$emit('openGroupModal'); }, }, }; diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue new file mode 100644 index 0000000000000000000000000000000000000000..6598000c46491e4ed0bdaa71812f49a328b34999 --- /dev/null +++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue @@ -0,0 +1,146 @@ +<script> +import { uniqueId } from 'lodash'; +import Api from '~/api'; +import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; +import { GROUP_FILTERS, GROUP_MODAL_LABELS } from '../constants'; +import eventHub from '../event_hub'; +import GroupSelect from './group_select.vue'; +import InviteModalBase from './invite_modal_base.vue'; + +export default { + name: 'InviteMembersModal', + components: { + GroupSelect, + InviteModalBase, + }, + props: { + id: { + type: String, + required: true, + }, + isProject: { + type: Boolean, + required: true, + }, + name: { + type: String, + required: true, + }, + accessLevels: { + type: Object, + required: true, + }, + defaultAccessLevel: { + type: Number, + required: true, + }, + helpLink: { + type: String, + required: true, + }, + groupSelectFilter: { + type: String, + required: false, + default: GROUP_FILTERS.ALL, + }, + groupSelectParentId: { + type: Number, + required: false, + default: null, + }, + invalidGroups: { + type: Array, + required: true, + }, + }, + data() { + return { + modalId: uniqueId('invite-groups-modal-'), + groupToBeSharedWith: {}, + }; + }, + computed: { + labelIntroText() { + return this.$options.labels[this.inviteTo].introText; + }, + inviteTo() { + return this.isProject ? 'toProject' : 'toGroup'; + }, + toastOptions() { + return { + onComplete: () => { + this.groupToBeSharedWith = {}; + }, + }; + }, + inviteDisabled() { + return Object.keys(this.groupToBeSharedWith).length === 0; + }, + }, + mounted() { + eventHub.$on('openGroupModal', () => { + this.openModal(); + }); + }, + methods: { + openModal() { + this.$root.$emit(BV_SHOW_MODAL, this.modalId); + }, + closeModal() { + this.$root.$emit(BV_HIDE_MODAL, this.modalId); + }, + sendInvite({ onError, onSuccess, data: { accessLevel, expiresAt } }) { + const apiShareWithGroup = this.isProject + ? Api.projectShareWithGroup.bind(Api) + : Api.groupShareWithGroup.bind(Api); + + apiShareWithGroup(this.id, { + format: 'json', + group_id: this.groupToBeSharedWith.id, + group_access: accessLevel, + expires_at: expiresAt, + }) + .then(() => { + onSuccess(); + this.showSuccessMessage(); + }) + .catch(onError); + }, + resetFields() { + this.groupToBeSharedWith = {}; + }, + showSuccessMessage() { + this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); + this.closeModal(); + }, + }, + labels: GROUP_MODAL_LABELS, +}; +</script> +<template> + <invite-modal-base + :modal-id="modalId" + :modal-title="$options.labels.title" + :name="name" + :access-levels="accessLevels" + :default-access-level="defaultAccessLevel" + :help-link="helpLink" + v-bind="$attrs" + :label-intro-text="labelIntroText" + :label-search-field="$options.labels.searchField" + :submit-disabled="inviteDisabled" + @reset="resetFields" + @submit="sendInvite" + > + <template #select="{ clearValidation }"> + <group-select + v-model="groupToBeSharedWith" + :access-levels="accessLevels" + :groups-filter="groupSelectFilter" + :parent-group-id="groupSelectParentId" + :invalid-groups="invalidGroups" + @input="clearValidation" + /> + </template> + </invite-modal-base> +</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 16a2de737cafdb2dbeb8a58e9b318dae80e7a2ef..a9bf1ccf6bbc09180e635847b50c20d5d4305443 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -1,56 +1,40 @@ <script> import { GlAlert, - GlFormGroup, - GlModal, GlDropdown, GlDropdownItem, - GlDatepicker, GlLink, GlSprintf, - GlButton, - GlFormInput, GlFormCheckboxGroup, } from '@gitlab/ui'; -import { partition, isString, unescape, uniqueId } from 'lodash'; +import { partition, isString, uniqueId } from 'lodash'; import Api from '~/api'; import ExperimentTracking from '~/experimentation/experiment_tracking'; -import { sanitize } from '~/lib/dompurify'; -import { BV_SHOW_MODAL } from '~/lib/utils/constants'; +import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { getParameterValues } from '~/lib/utils/url_utility'; -import { sprintf } from '~/locale'; import { - GROUP_FILTERS, USERS_FILTER_ALL, INVITE_MEMBERS_FOR_TASK, - MODAL_LABELS, + MEMBER_MODAL_LABELS, LEARN_GITLAB, } from '../constants'; import eventHub from '../event_hub'; -import { - responseMessageFromError, - responseMessageFromSuccess, -} from '../utils/response_message_parser'; +import { responseMessageFromSuccess } from '../utils/response_message_parser'; import ModalConfetti from './confetti.vue'; -import GroupSelect from './group_select.vue'; +import InviteModalBase from './invite_modal_base.vue'; import MembersTokenSelect from './members_token_select.vue'; export default { name: 'InviteMembersModal', components: { GlAlert, - GlFormGroup, - GlDatepicker, GlLink, - GlModal, GlDropdown, GlDropdownItem, GlSprintf, - GlButton, - GlFormInput, GlFormCheckboxGroup, + InviteModalBase, MembersTokenSelect, - GroupSelect, ModalConfetti, }, inject: ['newProjectPath'], @@ -75,15 +59,9 @@ export default { type: Number, required: true, }, - groupSelectFilter: { + helpLink: { type: String, - required: false, - default: GROUP_FILTERS.ALL, - }, - groupSelectParentId: { - type: Number, - required: false, - default: null, + required: true, }, usersFilter: { type: String, @@ -95,10 +73,6 @@ export default { required: false, default: null, }, - helpLink: { - type: String, - required: true, - }, tasksToBeDoneOptions: { type: Array, required: true, @@ -107,80 +81,34 @@ export default { type: Array, required: true, }, - invalidGroups: { - type: Array, - required: true, - }, }, data() { return { - visible: true, modalId: uniqueId('invite-members-modal-'), - selectedAccessLevel: this.defaultAccessLevel, - inviteeType: 'members', newUsersToInvite: [], - selectedDate: undefined, selectedTasksToBeDone: [], selectedTaskProject: this.projects[0], - groupToBeSharedWith: {}, source: 'unknown', - invalidFeedbackMessage: '', - isLoading: false, mode: 'default', + // Kept in sync with "base" + selectedAccessLevel: undefined, }; }, computed: { isCelebration() { return this.mode === 'celebrate'; }, - validationState() { - return this.invalidFeedbackMessage === '' ? null : false; - }, - isInviteGroup() { - return this.inviteeType === 'group'; - }, modalTitle() { - return this.$options.labels[this.inviteeType].modal[this.mode].title; - }, - introText() { - return sprintf(this.$options.labels[this.inviteeType][this.inviteTo][this.mode].introText, { - name: this.name, - }); + return this.$options.labels.modal[this.mode].title; }, inviteTo() { return this.isProject ? 'toProject' : 'toGroup'; }, - toastOptions() { - return { - onComplete: () => { - this.selectedAccessLevel = this.defaultAccessLevel; - this.newUsersToInvite = []; - this.groupToBeSharedWith = {}; - }, - }; - }, - basePostData() { - return { - expires_at: this.selectedDate, - format: 'json', - }; - }, - selectedRoleName() { - return Object.keys(this.accessLevels).find( - (key) => this.accessLevels[key] === Number(this.selectedAccessLevel), - ); + labelIntroText() { + return this.$options.labels[this.inviteTo][this.mode].introText; }, inviteDisabled() { - return ( - this.newUsersToInvite.length === 0 && Object.keys(this.groupToBeSharedWith).length === 0 - ); - }, - errorFieldDescription() { - if (this.inviteeType === 'group') { - return ''; - } - - return this.$options.labels[this.inviteeType].placeHolder; + return this.newUsersToInvite.length === 0; }, tasksToBeDoneEnabled() { return ( @@ -219,7 +147,7 @@ export default { }); if (this.tasksToBeDoneEnabled) { - this.openModal({ inviteeType: 'members', source: 'in_product_marketing_email' }); + this.openModal({ source: 'in_product_marketing_email' }); this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, INVITE_MEMBERS_FOR_TASK.view); } }, @@ -235,72 +163,42 @@ export default { usersToAddById.map((user) => user.id).join(','), ]; }, - openModal({ mode = 'default', inviteeType, source }) { + openModal({ mode = 'default', source }) { this.mode = mode; - this.inviteeType = inviteeType; this.source = source; this.$root.$emit(BV_SHOW_MODAL, this.modalId); }, + closeModal() { + this.$root.$emit(BV_HIDE_MODAL, this.modalId); + }, trackEvent(experimentName, eventName) { const tracking = new ExperimentTracking(experimentName); tracking.event(eventName); }, - closeModal() { - this.resetFields(); - this.$refs.modal.hide(); - }, - sendInvite() { - if (this.isInviteGroup) { - this.submitShareWithGroup(); - } else { - this.submitInviteMembers(); - } - }, - trackinviteMembersForTask() { - const label = 'selected_tasks_to_be_done'; - const property = this.selectedTasksToBeDone.join(','); - const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property }); - tracking.event(INVITE_MEMBERS_FOR_TASK.submit); - }, - resetFields() { - this.isLoading = false; - this.selectedAccessLevel = this.defaultAccessLevel; - this.selectedDate = undefined; - this.newUsersToInvite = []; - this.groupToBeSharedWith = {}; - this.invalidFeedbackMessage = ''; - this.selectedTasksToBeDone = []; - [this.selectedTaskProject] = this.projects; - }, - changeSelectedItem(item) { - this.selectedAccessLevel = item; - }, - changeSelectedTaskProject(project) { - this.selectedTaskProject = project; - }, - submitShareWithGroup() { - const apiShareWithGroup = this.isProject - ? Api.projectShareWithGroup.bind(Api) - : Api.groupShareWithGroup.bind(Api); - - apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id)) - .then(this.showSuccessMessage) - .catch(this.showInvalidFeedbackMessage); - }, - submitInviteMembers() { - this.invalidFeedbackMessage = ''; - this.isLoading = true; - + sendInvite({ onError, onSuccess, data: { accessLevel, expiresAt } }) { const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite(); const promises = []; + const baseData = { + format: 'json', + expires_at: expiresAt, + access_level: accessLevel, + invite_source: this.source, + tasks_to_be_done: this.tasksToBeDoneForPost, + tasks_project_id: this.tasksProjectForPost, + }; if (usersToInviteByEmail !== '') { const apiInviteByEmail = this.isProject ? Api.inviteProjectMembersByEmail.bind(Api) : Api.inviteGroupMembersByEmail.bind(Api); - promises.push(apiInviteByEmail(this.id, this.inviteByEmailPostData(usersToInviteByEmail))); + promises.push( + apiInviteByEmail(this.id, { + ...baseData, + email: usersToInviteByEmail, + }), + ); } if (usersToAddById !== '') { @@ -308,190 +206,103 @@ export default { ? Api.addProjectMembersByUserId.bind(Api) : Api.addGroupMembersByUserId.bind(Api); - promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById))); + promises.push( + apiAddByUserId(this.id, { + ...baseData, + user_id: usersToAddById, + }), + ); } this.trackinviteMembersForTask(); Promise.all(promises) - .then(this.conditionallyShowSuccessMessage) - .catch(this.showInvalidFeedbackMessage); - }, - inviteByEmailPostData(usersToInviteByEmail) { - return { - ...this.basePostData, - email: usersToInviteByEmail, - access_level: this.selectedAccessLevel, - invite_source: this.source, - tasks_to_be_done: this.tasksToBeDoneForPost, - tasks_project_id: this.tasksProjectForPost, - }; + .then((responses) => { + const message = responseMessageFromSuccess(responses); + + if (message) { + onError({ + response: { + data: { + message, + }, + }, + }); + } else { + onSuccess(); + this.showSuccessMessage(); + } + }) + .catch(onError); }, - addByUserIdPostData(usersToAddById) { - return { - ...this.basePostData, - user_id: usersToAddById, - access_level: this.selectedAccessLevel, - invite_source: this.source, - tasks_to_be_done: this.tasksToBeDoneForPost, - tasks_project_id: this.tasksProjectForPost, - }; + trackinviteMembersForTask() { + const label = 'selected_tasks_to_be_done'; + const property = this.selectedTasksToBeDone.join(','); + const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property }); + tracking.event(INVITE_MEMBERS_FOR_TASK.submit); }, - shareWithGroupPostData(groupToBeSharedWith) { - return { - ...this.basePostData, - group_id: groupToBeSharedWith, - group_access: this.selectedAccessLevel, - }; + resetFields() { + this.newUsersToInvite = []; + this.selectedTasksToBeDone = []; + [this.selectedTaskProject] = this.projects; }, - conditionallyShowSuccessMessage(response) { - const message = this.unescapeMsg(responseMessageFromSuccess(response)); - - if (message === '') { - this.showSuccessMessage(); - - return; - } - - this.invalidFeedbackMessage = message; - this.isLoading = false; + changeSelectedTaskProject(project) { + this.selectedTaskProject = project; }, showSuccessMessage() { if (this.isOnLearnGitlab) { eventHub.$emit('showSuccessfulInvitationsAlert'); } else { - this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); + this.$toast.show(this.$options.labels.toastMessageSuccessful); } - this.closeModal(); - }, - showInvalidFeedbackMessage(response) { - const message = this.unescapeMsg(responseMessageFromError(response)); - this.isLoading = false; - this.invalidFeedbackMessage = message || this.$options.labels.invalidFeedbackMessageDefault; - }, - handleMembersTokenSelectClear() { - this.invalidFeedbackMessage = ''; + this.closeModal(); }, - unescapeMsg(message) { - return unescape(sanitize(message, { ALLOWED_TAGS: [] })); + onAccessLevelUpdate(val) { + this.selectedAccessLevel = val; }, }, - labels: MODAL_LABELS, - membersTokenSelectLabelId: 'invite-members-input', + labels: MEMBER_MODAL_LABELS, }; </script> <template> - <gl-modal - ref="modal" + <invite-modal-base :modal-id="modalId" - size="sm" - data-qa-selector="invite_members_modal_content" - data-testid="invite-members-modal" - :title="modalTitle" - :header-close-label="$options.labels.headerCloseLabel" - @hidden="resetFields" - @close="resetFields" - @hide="resetFields" + :modal-title="modalTitle" + :name="name" + :access-levels="accessLevels" + :default-access-level="defaultAccessLevel" + :help-link="helpLink" + :label-intro-text="labelIntroText" + :label-search-field="$options.labels.searchField" + :form-group-description="$options.labels.placeHolder" + :submit-disabled="inviteDisabled" + @reset="resetFields" + @submit="sendInvite" + @access-level="onAccessLevelUpdate" > - <div> - <div class="gl-display-flex"> - <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"><gl-emoji data-name="tada" /></div> - <div> - <p ref="introText"> - <gl-sprintf :message="introText"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - <br /> - <span v-if="isCelebration">{{ $options.labels.members.modal.celebrate.intro }} </span> - <modal-confetti v-if="isCelebration" /> - </p> - </div> - </div> - - <gl-form-group - :invalid-feedback="invalidFeedbackMessage" - :state="validationState" - :description="errorFieldDescription" - 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" - class="gl-mb-2" - :validation-state="validationState" - :aria-labelledby="$options.membersTokenSelectLabelId" - :users-filter="usersFilter" - :filter-id="filterId" - @clear="handleMembersTokenSelectClear" - /> - <group-select - v-if="isInviteGroup" - v-model="groupToBeSharedWith" - :access-levels="accessLevels" - :groups-filter="groupSelectFilter" - :parent-group-id="groupSelectParentId" - :invalid-groups="invalidGroups" - @input="handleMembersTokenSelectClear" - /> - </gl-form-group> - - <label class="gl-font-weight-bold">{{ $options.labels.accessLevel }}</label> - <div class="gl-mt-2 gl-w-half gl-xs-w-full"> - <gl-dropdown - class="gl-shadow-none gl-w-full" - data-qa-selector="access_level_dropdown" - v-bind="$attrs" - :text="selectedRoleName" - > - <template v-for="(key, item) in accessLevels"> - <gl-dropdown-item - :key="key" - active-class="is-active" - is-check-item - :is-checked="key === selectedAccessLevel" - @click="changeSelectedItem(key)" - > - <div>{{ item }}</div> - </gl-dropdown-item> - </template> - </gl-dropdown> - </div> - - <div class="gl-mt-2 gl-w-half gl-xs-w-full"> - <gl-sprintf :message="$options.labels.readMoreText"> - <template #link="{ content }"> - <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </div> - - <label class="gl-mt-5 gl-display-block" for="expires_at">{{ - $options.labels.accessExpireDate - }}</label> - <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block"> - <gl-datepicker - v-model="selectedDate" - class="gl-display-inline!" - :min-date="new Date()" - :target="null" - > - <template #default="{ formattedDate }"> - <gl-form-input - class="gl-w-full" - :value="formattedDate" - :placeholder="__(`YYYY-MM-DD`)" - /> - </template> - </gl-datepicker> - </div> + <template #intro-text-before> + <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"><gl-emoji data-name="tada" /></div> + </template> + <template #intro-text-after> + <br /> + <span v-if="isCelebration">{{ $options.labels.modal.celebrate.intro }} </span> + <modal-confetti v-if="isCelebration" /> + </template> + <template #select="{ clearValidation, validationState, labelId }"> + <members-token-select + v-model="newUsersToInvite" + class="gl-mb-2" + :validation-state="validationState" + :aria-labelledby="labelId" + :users-filter="usersFilter" + :filter-id="filterId" + @clear="clearValidation" + /> + </template> + <template #form-after> <div v-if="showTasksToBeDone" data-testid="invite-members-modal-tasks-to-be-done"> <label class="gl-mt-5"> - {{ $options.labels.members.tasksToBeDone.title }} + {{ $options.labels.tasksToBeDone.title }} </label> <template v-if="projects.length"> <gl-form-checkbox-group @@ -501,7 +312,7 @@ export default { /> <template v-if="showTaskProjects"> <label class="gl-mt-5 gl-display-block"> - {{ $options.labels.members.tasksProject.title }} + {{ $options.labels.tasksProject.title }} </label> <gl-dropdown class="gl-w-half gl-xs-w-full" @@ -528,7 +339,7 @@ export default { :dismissible="false" data-testid="invite-members-modal-no-projects-alert" > - <gl-sprintf :message="$options.labels.members.tasksToBeDone.noProjects"> + <gl-sprintf :message="$options.labels.tasksToBeDone.noProjects"> <template #link="{ content }"> <gl-link :href="newProjectPath" target="_blank" class="gl-label-link"> {{ content }} @@ -537,22 +348,6 @@ export default { </gl-sprintf> </gl-alert> </div> - </div> - - <template #modal-footer> - <gl-button data-testid="cancel-button" @click="closeModal"> - {{ $options.labels.cancelButtonText }} - </gl-button> - <gl-button - :disabled="inviteDisabled" - :loading="isLoading" - variant="success" - data-qa-selector="invite_button" - data-testid="invite-button" - @click="sendInvite" - > - {{ $options.labels.inviteButtonText }} - </gl-button> </template> - </gl-modal> + </invite-modal-base> </template> diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue new file mode 100644 index 0000000000000000000000000000000000000000..fc00f5b934342f5a0af095783572aa64b4b450be --- /dev/null +++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue @@ -0,0 +1,276 @@ +<script> +import { + GlFormGroup, + GlModal, + GlDropdown, + GlDropdownItem, + GlDatepicker, + GlLink, + GlSprintf, + GlButton, + GlFormInput, +} from '@gitlab/ui'; +import { unescape } from 'lodash'; +import { sanitize } from '~/lib/dompurify'; +import { sprintf } from '~/locale'; +import { + ACCESS_LEVEL, + ACCESS_EXPIRE_DATE, + INVALID_FEEDBACK_MESSAGE_DEFAULT, + READ_MORE_TEXT, + INVITE_BUTTON_TEXT, + CANCEL_BUTTON_TEXT, + HEADER_CLOSE_LABEL, +} from '../constants'; +import { responseMessageFromError } from '../utils/response_message_parser'; + +export default { + components: { + GlFormGroup, + GlDatepicker, + GlLink, + GlModal, + GlDropdown, + GlDropdownItem, + GlSprintf, + GlButton, + GlFormInput, + }, + inheritAttrs: false, + props: { + modalTitle: { + type: String, + required: true, + }, + modalId: { + type: String, + required: true, + }, + name: { + type: String, + required: true, + }, + accessLevels: { + type: Object, + required: true, + }, + defaultAccessLevel: { + type: Number, + required: true, + }, + helpLink: { + type: String, + required: true, + }, + labelIntroText: { + type: String, + required: true, + }, + labelSearchField: { + type: String, + required: true, + }, + formGroupDescription: { + type: String, + required: false, + default: '', + }, + submitDisabled: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + // Be sure to check out reset! + return { + invalidFeedbackMessage: '', + selectedAccessLevel: this.defaultAccessLevel, + selectedDate: undefined, + isLoading: false, + minDate: new Date(), + }; + }, + computed: { + introText() { + return sprintf(this.labelIntroText, { name: this.name }); + }, + validationState() { + return this.invalidFeedbackMessage ? false : null; + }, + selectLabelId() { + return `${this.modalId}_select`; + }, + selectedRoleName() { + return Object.keys(this.accessLevels).find( + (key) => this.accessLevels[key] === Number(this.selectedAccessLevel), + ); + }, + }, + watch: { + selectedAccessLevel: { + immediate: true, + handler(val) { + this.$emit('access-level', val); + }, + }, + }, + methods: { + showInvalidFeedbackMessage(response) { + const message = this.unescapeMsg(responseMessageFromError(response)); + + this.invalidFeedbackMessage = message || INVALID_FEEDBACK_MESSAGE_DEFAULT; + }, + reset() { + // This component isn't necessarily disposed, + // so we might need to reset it's state. + this.isLoading = false; + this.invalidFeedbackMessage = ''; + this.selectedAccessLevel = this.defaultAccessLevel; + this.selectedDate = undefined; + + this.$emit('reset'); + }, + closeModal() { + this.reset(); + this.$refs.modal.hide(); + }, + clearValidation() { + this.invalidFeedbackMessage = ''; + }, + changeSelectedItem(item) { + this.selectedAccessLevel = item; + }, + submit() { + this.isLoading = true; + this.invalidFeedbackMessage = ''; + + this.$emit('submit', { + onSuccess: () => { + this.isLoading = false; + }, + onError: (...args) => { + this.isLoading = false; + this.showInvalidFeedbackMessage(...args); + }, + data: { + accessLevel: this.selectedAccessLevel, + expiresAt: this.selectedDate, + }, + }); + }, + unescapeMsg(message) { + return unescape(sanitize(message, { ALLOWED_TAGS: [] })); + }, + }, + HEADER_CLOSE_LABEL, + ACCESS_EXPIRE_DATE, + ACCESS_LEVEL, + READ_MORE_TEXT, + INVITE_BUTTON_TEXT, + CANCEL_BUTTON_TEXT, +}; +</script> + +<template> + <gl-modal + ref="modal" + :modal-id="modalId" + data-qa-selector="invite_members_modal_content" + data-testid="invite-modal" + size="sm" + :title="modalTitle" + :header-close-label="$options.HEADER_CLOSE_LABEL" + @hidden="reset" + @close="reset" + @hide="reset" + > + <div class="gl-display-flex" data-testid="modal-base-intro-text"> + <slot name="intro-text-before"></slot> + <p> + <gl-sprintf :message="introText"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </p> + <slot name="intro-text-after"></slot> + </div> + + <gl-form-group + :invalid-feedback="invalidFeedbackMessage" + :state="validationState" + :description="formGroupDescription" + data-testid="members-form-group" + > + <label :id="selectLabelId" class="col-form-label">{{ labelSearchField }}</label> + <slot + name="select" + v-bind="{ clearValidation, validationState, labelId: selectLabelId }" + ></slot> + </gl-form-group> + + <label class="gl-font-weight-bold">{{ $options.ACCESS_LEVEL }}</label> + <div class="gl-mt-2 gl-w-half gl-xs-w-full"> + <gl-dropdown + class="gl-shadow-none gl-w-full" + data-qa-selector="access_level_dropdown" + v-bind="$attrs" + :text="selectedRoleName" + > + <template v-for="(key, item) in accessLevels"> + <gl-dropdown-item + :key="key" + active-class="is-active" + is-check-item + :is-checked="key === selectedAccessLevel" + @click="changeSelectedItem(key)" + > + <div>{{ item }}</div> + </gl-dropdown-item> + </template> + </gl-dropdown> + </div> + + <div class="gl-mt-2 gl-w-half gl-xs-w-full"> + <gl-sprintf :message="$options.READ_MORE_TEXT"> + <template #link="{ content }"> + <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> + + <label class="gl-mt-5 gl-display-block" for="expires_at">{{ + $options.ACCESS_EXPIRE_DATE + }}</label> + <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block"> + <gl-datepicker + v-model="selectedDate" + class="gl-display-inline!" + :min-date="minDate" + :target="null" + > + <template #default="{ formattedDate }"> + <gl-form-input class="gl-w-full" :value="formattedDate" :placeholder="__(`YYYY-MM-DD`)" /> + </template> + </gl-datepicker> + </div> + <slot name="form-after"></slot> + + <template #modal-footer> + <gl-button data-testid="cancel-button" @click="closeModal"> + {{ $options.CANCEL_BUTTON_TEXT }} + </gl-button> + <gl-button + :disabled="submitDisabled" + :loading="isLoading" + variant="success" + data-qa-selector="invite_button" + data-testid="invite-button" + @click="submit" + > + {{ $options.INVITE_BUTTON_TEXT }} + </gl-button> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index ec59b3909fe15ecf8b183db2adad9ca379b2764a..cf2ee50818424d8a91f780ebad86b25e879dc7ac 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -72,67 +72,52 @@ export const INVITE_BUTTON_TEXT = s__('InviteMembersModal|Invite'); export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel'); export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members'); -export const MODAL_LABELS = { - members: { - modal: { - default: { - title: MEMBERS_MODAL_DEFAULT_TITLE, - }, - celebrate: { - title: MEMBERS_MODAL_CELEBRATE_TITLE, - intro: MEMBERS_MODAL_CELEBRATE_INTRO, - }, +export const MEMBER_MODAL_LABELS = { + modal: { + default: { + title: MEMBERS_MODAL_DEFAULT_TITLE, }, - toGroup: { - default: { - introText: MEMBERS_TO_GROUP_DEFAULT_INTRO_TEXT, - }, - }, - toProject: { - default: { - introText: MEMBERS_TO_PROJECT_DEFAULT_INTRO_TEXT, - }, - celebrate: { - introText: MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT, - }, - }, - searchField: MEMBERS_SEARCH_FIELD, - placeHolder: MEMBERS_PLACEHOLDER, - tasksToBeDone: { - title: MEMBERS_TASKS_TO_BE_DONE_TITLE, - noProjects: MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS, - }, - tasksProject: { - title: MEMBERS_TASKS_PROJECTS_TITLE, + celebrate: { + title: MEMBERS_MODAL_CELEBRATE_TITLE, + intro: MEMBERS_MODAL_CELEBRATE_INTRO, }, }, - group: { - modal: { - default: { - title: GROUP_MODAL_DEFAULT_TITLE, - }, + toGroup: { + default: { + introText: MEMBERS_TO_GROUP_DEFAULT_INTRO_TEXT, }, - toGroup: { - default: { - introText: GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT, - }, + }, + toProject: { + default: { + introText: MEMBERS_TO_PROJECT_DEFAULT_INTRO_TEXT, }, - toProject: { - default: { - introText: GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT, - }, + celebrate: { + introText: MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT, }, - searchField: GROUP_SEARCH_FIELD, - placeHolder: GROUP_PLACEHOLDER, }, - accessLevel: ACCESS_LEVEL, - accessExpireDate: ACCESS_EXPIRE_DATE, + searchField: MEMBERS_SEARCH_FIELD, + placeHolder: MEMBERS_PLACEHOLDER, + tasksToBeDone: { + title: MEMBERS_TASKS_TO_BE_DONE_TITLE, + noProjects: MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS, + }, + tasksProject: { + title: MEMBERS_TASKS_PROJECTS_TITLE, + }, + toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL, +}; + +export const GROUP_MODAL_LABELS = { + title: GROUP_MODAL_DEFAULT_TITLE, + toGroup: { + introText: GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT, + }, + toProject: { + introText: GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT, + }, + searchField: GROUP_SEARCH_FIELD, + placeHolder: GROUP_PLACEHOLDER, toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL, - invalidFeedbackMessageDefault: INVALID_FEEDBACK_MESSAGE_DEFAULT, - readMoreText: READ_MORE_TEXT, - inviteButtonText: INVITE_BUTTON_TEXT, - cancelButtonText: CANCEL_BUTTON_TEXT, - headerCloseLabel: HEADER_CLOSE_LABEL, }; export const LEARN_GITLAB = 'learn_gitlab'; diff --git a/app/assets/javascripts/invite_members/init_invite_groups_modal.js b/app/assets/javascripts/invite_members/init_invite_groups_modal.js new file mode 100644 index 0000000000000000000000000000000000000000..be1576ad0b00da74997ef9adb5e3955a69553dca --- /dev/null +++ b/app/assets/javascripts/invite_members/init_invite_groups_modal.js @@ -0,0 +1,44 @@ +import { GlToast } from '@gitlab/ui'; +import Vue from 'vue'; +import InviteGroupsModal from '~/invite_members/components/invite_groups_modal.vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; + +Vue.use(GlToast); + +let initedInviteGroupsModal; + +export default function initInviteGroupsModal() { + if (initedInviteGroupsModal) { + // if we already loaded this in another part of the dom, we don't want to do it again + // else we will stack the modals + return false; + } + + // https://gitlab.com/gitlab-org/gitlab/-/issues/344955 + // bug lying in wait here for someone to put group and project invite in same screen + // once that happens we'll need to mount these differently, perhaps split + // group/project to each mount one, with many ways to open it. + const el = document.querySelector('.js-invite-groups-modal'); + + if (!el) { + return false; + } + + initedInviteGroupsModal = true; + + return new Vue({ + el, + render: (createElement) => + createElement(InviteGroupsModal, { + props: { + ...el.dataset, + isProject: parseBoolean(el.dataset.isProject), + accessLevels: JSON.parse(el.dataset.accessLevels), + defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10), + groupSelectFilter: el.dataset.groupsFilter, + groupSelectParentId: parseInt(el.dataset.parentId, 10), + invalidGroups: JSON.parse(el.dataset.invalidGroups || '[]'), + }, + }), + }); +} diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js index 27e23a4b305327288309650bb80ede597861efa5..588b1c9ef52ecac3239f5754134758a260d2aef6 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -38,9 +38,6 @@ export default function initInviteMembersModal() { isProject: parseBoolean(el.dataset.isProject), accessLevels: JSON.parse(el.dataset.accessLevels), defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10), - groupSelectFilter: el.dataset.groupsFilter, - groupSelectParentId: parseInt(el.dataset.parentId, 10), - invalidGroups: JSON.parse(el.dataset.invalidGroups || '[]'), tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'), projects: JSON.parse(el.dataset.projects || '[]'), usersFilter: el.dataset.usersFilter, diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index 01a371920f8a390528b9b474eed6b870a715e97f..14ce3f775b1063fe49f36e0507a92a6a1604a28c 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -1,6 +1,7 @@ import { groupMemberRequestFormatter } from '~/groups/members/utils'; import groupsSelect from '~/groups_select'; import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger'; +import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal'; import initInviteMembersForm from '~/invite_members/init_invite_members_form'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; @@ -56,6 +57,7 @@ groupsSelect(); memberExpirationDate(); memberExpirationDate('.js-access-expiration-date-groups'); initInviteMembersModal(); +initInviteGroupsModal(); initInviteMembersTrigger(); initInviteGroupTrigger(); diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index 947bbdacf2c31ad19f6f1b0d7b3597a6d99db26c..26c42247cf7594d51ccdfd8a5ab238f0f48c28d4 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -3,6 +3,7 @@ import initImportAProjectModal from '~/invite_members/init_import_a_project_moda import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger'; import initInviteMembersForm from '~/invite_members/init_invite_members_form'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; +import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { s__ } from '~/locale'; import memberExpirationDate from '~/member_expiration_date'; @@ -17,6 +18,7 @@ memberExpirationDate(); memberExpirationDate('.js-access-expiration-date-groups'); initImportAProjectModal(); initInviteMembersModal(); +initInviteGroupsModal(); initInviteMembersTrigger(); initInviteGroupTrigger(); diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb index 49f1dd76e96d3708fa8762625407911774b52da3..884386ab8ac0fd436c0a4f4bee098b070d1f2ebe 100644 --- a/app/helpers/invite_members_helper.rb +++ b/app/helpers/invite_members_helper.rb @@ -33,12 +33,23 @@ def group_select_data(group) end end + def common_invite_group_modal_data(source, member_class, is_project) + { + id: source.id, + name: source.name, + default_access_level: Gitlab::Access::GUEST, + invalid_groups: source.related_group_ids, + help_link: help_page_url('user/permissions'), + is_project: is_project, + access_levels: member_class.access_level_roles.to_json + } + end + def common_invite_modal_dataset(source) dataset = { id: source.id, name: source.name, - default_access_level: Gitlab::Access::GUEST, - invalid_groups: source.related_group_ids + default_access_level: Gitlab::Access::GUEST } if show_invite_members_for_task?(source) diff --git a/app/views/groups/_invite_groups_modal.html.haml b/app/views/groups/_invite_groups_modal.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..22ef319348a0c5ac3878a9dbedb351c6c7eb70c9 --- /dev/null +++ b/app/views/groups/_invite_groups_modal.html.haml @@ -0,0 +1,3 @@ +- return unless can_admin_group_member?(group) + +.js-invite-groups-modal{ data: common_invite_group_modal_data(group, GroupMember, 'false').merge(group_select_data(group)) } diff --git a/app/views/groups/_invite_members_modal.html.haml b/app/views/groups/_invite_members_modal.html.haml index 78f079df158575e85a5a67b76417090b1a026ac3..786034fd2e747ebb33977e5f0a89f4e7b9f3a2bd 100644 --- a/app/views/groups/_invite_members_modal.html.haml +++ b/app/views/groups/_invite_members_modal.html.haml @@ -2,5 +2,4 @@ .js-invite-members-modal{ data: { is_project: 'false', access_levels: GroupMember.access_level_roles.to_json, - default_access_level: Gitlab::Access::GUEST, - help_link: help_page_url('user/permissions') }.merge(group_select_data(group)).merge(common_invite_modal_dataset(group)).merge(users_filter_data(group)) } + help_link: help_page_url('user/permissions') }.merge(common_invite_modal_dataset(group)).merge(users_filter_data(group)) } diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index e751a650412e4e209277855dfcfb6bf5bd303109..d1f56a509075dbb0b7064f3ddd36c7818164c1f7 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -19,6 +19,7 @@ classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', trigger_source: 'group-members-page', display_text: _('Invite members') } } + = render 'groups/invite_groups_modal', group: @group = render 'groups/invite_members_modal', group: @group - if can_admin_group_member?(@group) && Feature.disabled?(:invite_members_group_modal, @group, default_enabled: :yaml) %hr.gl-mt-4 diff --git a/app/views/projects/_invite_groups_modal.html.haml b/app/views/projects/_invite_groups_modal.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..d16e87d1c26f918e3ae78831c2cadbdaeb4f2c19 --- /dev/null +++ b/app/views/projects/_invite_groups_modal.html.haml @@ -0,0 +1,3 @@ +- return unless can_admin_project_member?(project) + +.js-invite-groups-modal{ data: common_invite_group_modal_data(project, ProjectMember, 'true') } diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 4a25bda3281a02492c9c0692ed2feb446a10f25a..220e44679cdd5ce8d0124d7cc32594c8b873c859 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -21,6 +21,7 @@ .js-import-a-project-modal{ data: { project_id: @project.id, project_name: @project.name } } - if @project.allowed_to_share_with_group? .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite a group') } } + = render 'projects/invite_groups_modal', project: @project - if can_admin_project_member?(@project) .js-invite-members-trigger{ data: { variant: 'success', classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', diff --git a/ee/spec/features/groups/members/list_members_spec.rb b/ee/spec/features/groups/members/list_members_spec.rb index 1a8e4623ded2c7bf55e8220bed265bc46d727d87..f67ce8568344250047da472598b049bdb8e2fab1 100644 --- a/ee/spec/features/groups/members/list_members_spec.rb +++ b/ee/spec/features/groups/members/list_members_spec.rb @@ -66,7 +66,7 @@ click_on 'Invite members' - page.within '[data-testid="invite-members-modal"]' do + page.within '[data-testid="invite-modal"]' do field = find('[data-testid="members-token-select-input"]') field.native.send_keys :tab field.click diff --git a/qa/qa/page/component/invite_members_modal.rb b/qa/qa/page/component/invite_members_modal.rb index 8fa87afa3044f02f6dd68437747ef1f595437437..7c536ff651b6c743fd106ac0fade3a1897c5492e 100644 --- a/qa/qa/page/component/invite_members_modal.rb +++ b/qa/qa/page/component/invite_members_modal.rb @@ -9,7 +9,7 @@ module InviteMembersModal def self.included(base) super - base.view 'app/assets/javascripts/invite_members/components/invite_members_modal.vue' do + base.view 'app/assets/javascripts/invite_members/components/invite_modal_base.vue' do element :invite_button element :access_level_dropdown element :invite_members_modal_content diff --git a/spec/frontend/invite_members/components/invite_group_trigger_spec.js b/spec/frontend/invite_members/components/invite_group_trigger_spec.js index cb9967ebe8c879fe0bcb9e154dbf6907b669543b..84ddb779a9e12791a38676e0e89b142aad1c8d45 100644 --- a/spec/frontend/invite_members/components/invite_group_trigger_spec.js +++ b/spec/frontend/invite_members/components/invite_group_trigger_spec.js @@ -44,7 +44,7 @@ describe('InviteGroupTrigger', () => { }); it('emits event that triggers opening the modal', () => { - expect(eventHub.$emit).toHaveBeenLastCalledWith('openModal', { inviteeType: 'group' }); + expect(eventHub.$emit).toHaveBeenLastCalledWith('openGroupModal'); }); }); }); diff --git a/spec/frontend/invite_members/components/invite_groups_modal_spec.js b/spec/frontend/invite_members/components/invite_groups_modal_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..49c55d56080cf1685856d82fee8a5a933973c0d8 --- /dev/null +++ b/spec/frontend/invite_members/components/invite_groups_modal_spec.js @@ -0,0 +1,143 @@ +import { GlModal, GlSprintf } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Api from '~/api'; +import InviteGroupsModal from '~/invite_members/components/invite_groups_modal.vue'; +import InviteModalBase from '~/invite_members/components/invite_modal_base.vue'; +import GroupSelect from '~/invite_members/components/group_select.vue'; +import { stubComponent } from 'helpers/stub_component'; +import { propsData, sharedGroup } from '../mock_data/group_modal'; + +describe('InviteGroupsModal', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(InviteGroupsModal, { + propsData: { + ...propsData, + ...props, + }, + stubs: { + InviteModalBase, + GlSprintf, + GlModal: stubComponent(GlModal, { + template: '<div><slot></slot><slot name="modal-footer"></slot></div>', + }), + }, + }); + }; + + const createInviteGroupToProjectWrapper = () => { + createComponent({ isProject: true }); + }; + + const createInviteGroupToGroupWrapper = () => { + createComponent({ isProject: false }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findGroupSelect = () => wrapper.findComponent(GroupSelect); + const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text(); + const findCancelButton = () => wrapper.findByTestId('cancel-button'); + const findInviteButton = () => wrapper.findByTestId('invite-button'); + const findMembersFormGroup = () => wrapper.findByTestId('members-form-group'); + const membersFormGroupInvalidFeedback = () => + findMembersFormGroup().attributes('invalid-feedback'); + const clickInviteButton = () => findInviteButton().vm.$emit('click'); + const clickCancelButton = () => findCancelButton().vm.$emit('click'); + const triggerGroupSelect = (val) => findGroupSelect().vm.$emit('input', val); + + describe('displaying the correct introText and form group description', () => { + describe('when inviting to a project', () => { + it('includes the correct type, and formatted intro text', () => { + createInviteGroupToProjectWrapper(); + + expect(findIntroText()).toBe("You're inviting a group to the test name project."); + }); + }); + + describe('when inviting to a group', () => { + it('includes the correct type, and formatted intro text', () => { + createInviteGroupToGroupWrapper(); + + expect(findIntroText()).toBe("You're inviting a group to the test name group."); + }); + }); + }); + + describe('submitting the invite form', () => { + describe('when sharing the group is successful', () => { + const groupPostData = { + group_id: sharedGroup.id, + group_access: propsData.defaultAccessLevel, + expires_at: undefined, + format: 'json', + }; + + beforeEach(() => { + createComponent(); + triggerGroupSelect(sharedGroup); + + wrapper.vm.$toast = { show: jest.fn() }; + jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData }); + + clickInviteButton(); + }); + + it('calls Api groupShareWithGroup with the correct params', () => { + expect(Api.groupShareWithGroup).toHaveBeenCalledWith(propsData.id, groupPostData); + }); + + it('displays the successful toastMessage', () => { + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', { + onComplete: expect.any(Function), + }); + }); + }); + + describe('when sharing the group fails', () => { + beforeEach(() => { + createInviteGroupToGroupWrapper(); + triggerGroupSelect(sharedGroup); + + wrapper.vm.$toast = { show: jest.fn() }; + + jest + .spyOn(Api, 'groupShareWithGroup') + .mockRejectedValue({ response: { data: { success: false } } }); + + clickInviteButton(); + }); + + it('does not show the toast message on failure', () => { + expect(wrapper.vm.$toast.show).not.toHaveBeenCalled(); + }); + + it('displays the generic error for http server error', () => { + expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong'); + }); + + describe('clearing the invalid state and message', () => { + it('clears the error when the cancel button is clicked', async () => { + clickCancelButton(); + + await nextTick(); + + expect(membersFormGroupInvalidFeedback()).toBe(''); + }); + + it('clears the error when the modal is hidden', async () => { + wrapper.findComponent(GlModal).vm.$emit('hide'); + + await nextTick(); + + expect(membersFormGroupInvalidFeedback()).toBe(''); + }); + }); + }); + }); +}); diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js index 72db6904c315503adfd594dbbe74f4bd8bfccb51..090efc4d4c351019c70c2b5807e977d3caf1ac1b 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -1,12 +1,4 @@ -import { - GlDropdown, - GlDropdownItem, - GlDatepicker, - GlFormGroup, - GlSprintf, - GlLink, - GlModal, -} from '@gitlab/ui'; +import { GlLink, GlModal, GlSprintf } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import { stubComponent } from 'helpers/stub_component'; @@ -15,15 +7,13 @@ 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 InviteModalBase from '~/invite_members/components/invite_modal_base.vue'; import ModalConfetti from '~/invite_members/components/confetti.vue'; import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; import { INVITE_MEMBERS_FOR_TASK, - CANCEL_BUTTON_TEXT, - INVITE_BUTTON_TEXT, MEMBERS_MODAL_CELEBRATE_INTRO, MEMBERS_MODAL_CELEBRATE_TITLE, - MEMBERS_MODAL_DEFAULT_TITLE, MEMBERS_PLACEHOLDER, MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT, LEARN_GITLAB, @@ -33,9 +23,16 @@ import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; import { getParameterValues } from '~/lib/utils/url_utility'; import { apiPaths, membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses'; - -let wrapper; -let mock; +import { + propsData, + inviteSource, + newProjectPath, + user1, + user2, + user3, + user4, + GlEmoji, +} from '../mock_data/member_modal'; jest.mock('~/experimentation/experiment_tracking'); jest.mock('~/lib/utils/url_utility', () => ({ @@ -43,213 +40,125 @@ jest.mock('~/lib/utils/url_utility', () => ({ getParameterValues: jest.fn(() => []), })); -const id = '1'; -const name = 'test name'; -const isProject = false; -const invalidGroups = []; -const inviteeType = 'members'; -const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }; -const defaultAccessLevel = 10; -const inviteSource = 'unknown'; -const helpLink = 'https://example.com'; -const tasksToBeDoneOptions = [ - { text: 'First task', value: 'first' }, - { text: 'Second task', value: 'second' }, -]; -const newProjectPath = 'projects/new'; -const projects = [ - { text: 'First project', value: '1' }, - { text: 'Second project', value: '2' }, -]; - -const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' }; -const user2 = { id: 2, name: 'Name Two', username: 'one_2', avatar_url: '' }; -const user3 = { - id: 'user-defined-token', - name: 'email@example.com', - 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 GlEmoji = { template: '<img/>' }; - -const createComponent = (data = {}, props = {}) => { - wrapper = shallowMountExtended(InviteMembersModal, { - provide: { - newProjectPath, - }, - propsData: { - id, - name, - isProject, - inviteeType, - accessLevels, - defaultAccessLevel, - tasksToBeDoneOptions, - projects, - helpLink, - invalidGroups, - ...props, - }, - data() { - return data; - }, - stubs: { - GlModal: stubComponent(GlModal, { - template: - '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>', - }), - GlDropdown: true, - GlDropdownItem: true, - GlEmoji, - GlSprintf, - GlFormGroup: stubComponent(GlFormGroup, { - props: ['state', 'invalidFeedback', 'description'], - }), - }, - }); -}; - -const createInviteMembersToProjectWrapper = () => { - createComponent({ inviteeType: 'members' }, { isProject: true }); -}; - -const createInviteMembersToGroupWrapper = () => { - createComponent({ inviteeType: 'members' }, { isProject: false }); -}; +describe('InviteMembersModal', () => { + let wrapper; + let mock; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(InviteMembersModal, { + provide: { + newProjectPath, + }, + propsData: { + ...propsData, + ...props, + }, + stubs: { + InviteModalBase, + GlSprintf, + GlModal: stubComponent(GlModal, { + template: '<div><slot></slot><slot name="modal-footer"></slot></div>', + }), + GlEmoji, + }, + }); + }; -const createInviteGroupToProjectWrapper = () => { - createComponent({ inviteeType: 'group' }, { isProject: true }); -}; + const createInviteMembersToProjectWrapper = () => { + createComponent({ isProject: true }); + }; -const createInviteGroupToGroupWrapper = () => { - createComponent({ inviteeType: 'group' }, { isProject: false }); -}; + const createInviteMembersToGroupWrapper = () => { + createComponent({ isProject: false }); + }; -beforeEach(() => { - gon.api_version = 'v4'; - mock = new MockAdapter(axios); -}); + beforeEach(() => { + gon.api_version = 'v4'; + mock = new MockAdapter(axios); + }); -afterEach(() => { - wrapper.destroy(); - wrapper = null; - mock.restore(); -}); + 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 findBase = () => wrapper.findComponent(InviteModalBase); + const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text(); const findCancelButton = () => wrapper.findByTestId('cancel-button'); const findInviteButton = () => wrapper.findByTestId('invite-button'); const clickInviteButton = () => findInviteButton().vm.$emit('click'); const clickCancelButton = () => findCancelButton().vm.$emit('click'); const findMembersFormGroup = () => wrapper.findByTestId('members-form-group'); - const membersFormGroupInvalidFeedback = () => findMembersFormGroup().props('invalidFeedback'); - const membersFormGroupDescription = () => findMembersFormGroup().props('description'); + const membersFormGroupInvalidFeedback = () => + findMembersFormGroup().attributes('invalid-feedback'); + const membersFormGroupDescription = () => findMembersFormGroup().attributes('description'); const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect); const findTasksToBeDone = () => wrapper.findByTestId('invite-members-modal-tasks-to-be-done'); const findTasks = () => wrapper.findByTestId('invite-members-modal-tasks'); const findProjectSelect = () => wrapper.findByTestId('invite-members-modal-project-select'); const findNoProjectsAlert = () => wrapper.findByTestId('invite-members-modal-no-projects-alert'); - const findCelebrationEmoji = () => wrapper.findComponent(GlModal).find(GlEmoji); - - describe('rendering the modal', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders the modal with the correct title', () => { - expect(wrapper.findComponent(GlModal).props('title')).toBe(MEMBERS_MODAL_DEFAULT_TITLE); - }); - - it('renders the Cancel button text correctly', () => { - expect(findCancelButton().text()).toBe(CANCEL_BUTTON_TEXT); - }); - - it('renders the Invite button text correctly', () => { - expect(findInviteButton().text()).toBe(INVITE_BUTTON_TEXT); - }); - - it('renders the Invite button modal without isLoading', () => { - expect(findInviteButton().props('loading')).toBe(false); - }); - - describe('rendering the access levels dropdown', () => { - it('sets the default dropdown text to the default access level name', () => { - expect(findDropdown().attributes('text')).toBe('Guest'); - }); - - it('renders dropdown items for each accessLevel', () => { - expect(findDropdownItems()).toHaveLength(5); - }); - }); - - describe('rendering the help link', () => { - it('renders the correct link', () => { - expect(findLink().attributes('href')).toBe(helpLink); - }); - }); - - describe('rendering the access expiration date field', () => { - it('renders the datepicker', () => { - expect(findDatepicker().exists()).toBe(true); - }); - }); - }); + const findCelebrationEmoji = () => wrapper.findComponent(GlEmoji); + const triggerOpenModal = async ({ mode = 'default', source }) => { + eventHub.$emit('openModal', { mode, source }); + await nextTick(); + }; + const triggerMembersTokenSelect = async (val) => { + findMembersSelect().vm.$emit('input', val); + await nextTick(); + }; + const triggerTasks = async (val) => { + findTasks().vm.$emit('input', val); + await nextTick(); + }; + const triggerAccessLevel = async (val) => { + findBase().vm.$emit('access-level', val); + await nextTick(); + }; describe('rendering the tasks to be done', () => { - const setupComponent = ( - extraData = {}, - props = {}, - urlParameter = ['invite_members_for_task'], - ) => { - const data = { - selectedAccessLevel: 30, - selectedTasksToBeDone: ['ci', 'code'], - ...extraData, - }; + const setupComponent = async (props = {}, urlParameter = ['invite_members_for_task']) => { getParameterValues.mockImplementation(() => urlParameter); - createComponent(data, props); + createComponent(props); + + await triggerAccessLevel(30); + }; + + const setupComponentWithTasks = async (...args) => { + await setupComponent(...args); + await triggerTasks(['ci', 'code']); }; afterAll(() => { getParameterValues.mockImplementation(() => []); }); - it('renders the tasks to be done', () => { - setupComponent(); + it('renders the tasks to be done', async () => { + await setupComponent(); expect(findTasksToBeDone().exists()).toBe(true); }); describe('when the selected access level is lower than 30', () => { - it('does not render the tasks to be done', () => { - setupComponent({ selectedAccessLevel: 20 }); + it('does not render the tasks to be done', async () => { + await setupComponent(); + await triggerAccessLevel(20); expect(findTasksToBeDone().exists()).toBe(false); }); }); describe('when the url does not contain the parameter `open_modal=invite_members_for_task`', () => { - it('does not render the tasks to be done', () => { - setupComponent({}, {}, []); + it('does not render the tasks to be done', async () => { + await setupComponent({}, []); expect(findTasksToBeDone().exists()).toBe(false); }); describe('when opened from the Learn GitLab page', () => { - it('does render the tasks to be done', () => { - setupComponent({ source: LEARN_GITLAB }, {}, []); + it('does render the tasks to be done', async () => { + await setupComponent({}, []); + await triggerOpenModal({ source: LEARN_GITLAB }); expect(findTasksToBeDone().exists()).toBe(true); }); @@ -257,27 +166,27 @@ describe('InviteMembersModal', () => { }); describe('rendering the tasks', () => { - it('renders the tasks', () => { - setupComponent(); + it('renders the tasks', async () => { + await setupComponent(); expect(findTasks().exists()).toBe(true); }); - it('does not render an alert', () => { - setupComponent(); + it('does not render an alert', async () => { + await setupComponent(); expect(findNoProjectsAlert().exists()).toBe(false); }); describe('when there are no projects passed in the data', () => { - it('does not render the tasks', () => { - setupComponent({}, { projects: [] }); + it('does not render the tasks', async () => { + await setupComponent({ projects: [] }); expect(findTasks().exists()).toBe(false); }); - it('renders an alert with a link to the new projects path', () => { - setupComponent({}, { projects: [] }); + it('renders an alert with a link to the new projects path', async () => { + await setupComponent({ projects: [] }); expect(findNoProjectsAlert().exists()).toBe(true); expect(findNoProjectsAlert().findComponent(GlLink).attributes('href')).toBe( @@ -288,23 +197,23 @@ describe('InviteMembersModal', () => { }); describe('rendering the project dropdown', () => { - it('renders the project select', () => { - setupComponent(); + it('renders the project select', async () => { + await setupComponentWithTasks(); expect(findProjectSelect().exists()).toBe(true); }); describe('when the modal is shown for a project', () => { - it('does not render the project select', () => { - setupComponent({}, { isProject: true }); + it('does not render the project select', async () => { + await setupComponentWithTasks({ isProject: true }); expect(findProjectSelect().exists()).toBe(false); }); }); describe('when no tasks are selected', () => { - it('does not render the project select', () => { - setupComponent({ selectedTasksToBeDone: [] }); + it('does not render the project select', async () => { + await setupComponent(); expect(findProjectSelect().exists()).toBe(false); }); @@ -312,8 +221,8 @@ describe('InviteMembersModal', () => { }); describe('tracking events', () => { - it('tracks the view for invite_members_for_task', () => { - setupComponent(); + it('tracks the view for invite_members_for_task', async () => { + await setupComponentWithTasks(); expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name); expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith( @@ -321,8 +230,8 @@ describe('InviteMembersModal', () => { ); }); - it('tracks the submit for invite_members_for_task', () => { - setupComponent(); + it('tracks the submit for invite_members_for_task', async () => { + await setupComponentWithTasks(); clickInviteButton(); expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name, { @@ -355,8 +264,9 @@ describe('InviteMembersModal', () => { }); describe('when inviting members with celebration', () => { - beforeEach(() => { - createComponent({ mode: 'celebrate', inviteeType: 'members' }, { isProject: true }); + beforeEach(async () => { + createComponent({ isProject: true }); + await triggerOpenModal({ mode: 'celebrate' }); }); it('renders the modal with confetti', () => { @@ -375,34 +285,14 @@ describe('InviteMembersModal', () => { expect(membersFormGroupDescription()).toBe(MEMBERS_PLACEHOLDER); }); }); - - describe('when sharing with a group', () => { - it('includes the correct invitee, type, and formatted name', () => { - createInviteGroupToProjectWrapper(); - - expect(findIntroText()).toBe("You're inviting a group to the test name project."); - expect(membersFormGroupDescription()).toBe(''); - }); - }); }); describe('when inviting to a group', () => { - describe('when inviting members', () => { - it('includes the correct invitee, type, and formatted name', () => { - createInviteMembersToGroupWrapper(); - - expect(findIntroText()).toBe("You're inviting members to the test name group."); - expect(membersFormGroupDescription()).toBe(MEMBERS_PLACEHOLDER); - }); - }); - - describe('when sharing with a group', () => { - it('includes the correct invitee, type, and formatted name', () => { - createInviteGroupToGroupWrapper(); + it('includes the correct invitee, type, and formatted name', () => { + createInviteMembersToGroupWrapper(); - expect(findIntroText()).toBe("You're inviting a group to the test name group."); - expect(membersFormGroupDescription()).toBe(''); - }); + expect(findIntroText()).toBe("You're inviting members to the test name group."); + expect(membersFormGroupDescription()).toBe(MEMBERS_PLACEHOLDER); }); }); }); @@ -422,7 +312,7 @@ describe('InviteMembersModal', () => { describe('when inviting an existing user to group by user ID', () => { const postData = { user_id: '1,2', - access_level: defaultAccessLevel, + access_level: propsData.defaultAccessLevel, expires_at: undefined, invite_source: inviteSource, format: 'json', @@ -431,8 +321,9 @@ describe('InviteMembersModal', () => { }; describe('when member is added successfully', () => { - beforeEach(() => { - createComponent({ newUsersToInvite: [user1, user2] }); + beforeEach(async () => { + createComponent(); + await triggerMembersTokenSelect([user1, user2]); wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); @@ -448,19 +339,17 @@ describe('InviteMembersModal', () => { }); it('calls Api addGroupMembersByUserId with the correct params', () => { - expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, postData); + expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, postData); }); it('displays the successful toastMessage', () => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', { - onComplete: expect.any(Function), - }); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added'); }); }); describe('when opened from a Learn GitLab page', () => { it('emits the `showSuccessfulInvitationsAlert` event', async () => { - eventHub.$emit('openModal', { inviteeType: 'members', source: LEARN_GITLAB }); + await triggerOpenModal({ source: LEARN_GITLAB }); jest.spyOn(eventHub, '$emit').mockImplementation(); @@ -474,12 +363,10 @@ describe('InviteMembersModal', () => { }); describe('when member is not added successfully', () => { - beforeEach(() => { + beforeEach(async () => { createInviteMembersToGroupWrapper(); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ newUsersToInvite: [user1] }); + await triggerMembersTokenSelect([user1]); }); it('displays "Member already exists" api message for http status conflict', async () => { @@ -490,7 +377,6 @@ describe('InviteMembersModal', () => { await waitForPromises(); expect(membersFormGroupInvalidFeedback()).toBe('Member already exists'); - expect(findMembersFormGroup().props('state')).toBe(false); expect(findMembersSelect().props('validationState')).toBe(false); expect(findInviteButton().props('loading')).toBe(false); }); @@ -506,7 +392,6 @@ describe('InviteMembersModal', () => { it('clears the error when the list of members to invite is cleared', async () => { expect(membersFormGroupInvalidFeedback()).toBe('Member already exists'); - expect(findMembersFormGroup().props('state')).toBe(false); expect(findMembersSelect().props('validationState')).toBe(false); findMembersSelect().vm.$emit('clear'); @@ -514,7 +399,6 @@ describe('InviteMembersModal', () => { await nextTick(); expect(membersFormGroupInvalidFeedback()).toBe(''); - expect(findMembersFormGroup().props('state')).not.toBe(false); expect(findMembersSelect().props('validationState')).not.toBe(false); }); @@ -524,7 +408,6 @@ describe('InviteMembersModal', () => { await nextTick(); expect(membersFormGroupInvalidFeedback()).toBe(''); - expect(findMembersFormGroup().props('state')).not.toBe(false); expect(findMembersSelect().props('validationState')).not.toBe(false); }); @@ -534,7 +417,6 @@ describe('InviteMembersModal', () => { await nextTick(); expect(membersFormGroupInvalidFeedback()).toBe(''); - expect(findMembersFormGroup().props('state')).not.toBe(false); expect(findMembersSelect().props('validationState')).not.toBe(false); }); }); @@ -547,7 +429,6 @@ describe('InviteMembersModal', () => { await waitForPromises(); expect(membersFormGroupInvalidFeedback()).toBe('Member already exists'); - expect(findMembersFormGroup().props('state')).toBe(false); expect(findMembersSelect().props('validationState')).toBe(false); expect(findInviteButton().props('loading')).toBe(false); @@ -556,8 +437,7 @@ describe('InviteMembersModal', () => { await waitForPromises(); expect(membersFormGroupInvalidFeedback()).toBe(''); - expect(findMembersFormGroup().props('state')).not.toBe(false); - expect(findMembersSelect().props('validationState')).not.toBe(false); + expect(findMembersSelect().props('validationState')).toBe(null); expect(findInviteButton().props('loading')).toBe(false); }); @@ -611,7 +491,7 @@ describe('InviteMembersModal', () => { describe('when inviting a new user by email address', () => { const postData = { - access_level: defaultAccessLevel, + access_level: propsData.defaultAccessLevel, expires_at: undefined, email: 'email@example.com', invite_source: inviteSource, @@ -621,8 +501,9 @@ describe('InviteMembersModal', () => { }; describe('when invites are sent successfully', () => { - beforeEach(() => { - createComponent({ newUsersToInvite: [user3] }); + beforeEach(async () => { + createComponent(); + await triggerMembersTokenSelect([user3]); wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); @@ -634,24 +515,20 @@ describe('InviteMembersModal', () => { }); it('calls Api inviteGroupMembersByEmail with the correct params', () => { - expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, postData); + expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, postData); }); it('displays the successful toastMessage', () => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', { - onComplete: expect.any(Function), - }); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added'); }); }); }); describe('when invites are not sent successfully', () => { - beforeEach(() => { + beforeEach(async () => { createInviteMembersToGroupWrapper(); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ newUsersToInvite: [user3] }); + await triggerMembersTokenSelect([user3]); }); it('displays the api error for invalid email syntax', async () => { @@ -686,9 +563,7 @@ describe('InviteMembersModal', () => { await waitForPromises(); - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', { - onComplete: expect.any(Function), - }); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added'); expect(findMembersSelect().props('validationState')).toBe(null); }); @@ -719,9 +594,7 @@ describe('InviteMembersModal', () => { it('displays the invalid syntax error if one of the emails is invalid', async () => { createInviteMembersToGroupWrapper(); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ newUsersToInvite: [user3, user4] }); + await triggerMembersTokenSelect([user3, user4]); mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.ERROR_EMAIL_INVALID); clickInviteButton(); @@ -736,7 +609,7 @@ describe('InviteMembersModal', () => { describe('when inviting members and non-members in same click', () => { const postData = { - access_level: defaultAccessLevel, + access_level: propsData.defaultAccessLevel, expires_at: undefined, invite_source: inviteSource, format: 'json', @@ -748,8 +621,9 @@ describe('InviteMembersModal', () => { const idPostData = { ...postData, user_id: '1' }; describe('when invites are sent successfully', () => { - beforeEach(() => { - createComponent({ newUsersToInvite: [user1, user3] }); + beforeEach(async () => { + createComponent(); + await triggerMembersTokenSelect([user1, user3]); wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); @@ -762,30 +636,28 @@ describe('InviteMembersModal', () => { }); it('calls Api inviteGroupMembersByEmail with the correct params', () => { - expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, emailPostData); + expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, emailPostData); }); it('calls Api addGroupMembersByUserId with the correct params', () => { - expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, idPostData); + expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, idPostData); }); it('displays the successful toastMessage', () => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', { - onComplete: expect.any(Function), - }); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added'); }); }); - it('calls Apis with the invite source passed through to openModal', () => { - eventHub.$emit('openModal', { inviteeType: 'members', source: '_invite_source_' }); + it('calls Apis with the invite source passed through to openModal', async () => { + await triggerOpenModal({ source: '_invite_source_' }); clickInviteButton(); - expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, { + expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, { ...emailPostData, invite_source: '_invite_source_', }); - expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, { + expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, { ...idPostData, invite_source: '_invite_source_', }); @@ -793,12 +665,10 @@ describe('InviteMembersModal', () => { }); describe('when any invite failed for any reason', () => { - beforeEach(() => { + beforeEach(async () => { createInviteMembersToGroupWrapper(); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ newUsersToInvite: [user1, user3] }); + await triggerMembersTokenSelect([user1, user3]); mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID); mockMembersApi(httpStatus.OK, '200 OK'); @@ -814,64 +684,10 @@ describe('InviteMembersModal', () => { }); }); - describe('when inviting a group to share', () => { - describe('when sharing the group is successful', () => { - const groupPostData = { - group_id: sharedGroup.id, - group_access: defaultAccessLevel, - expires_at: undefined, - format: 'json', - }; - - beforeEach(() => { - createComponent({ groupToBeSharedWith: sharedGroup }); - - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ inviteeType: 'group' }); - wrapper.vm.$toast = { show: jest.fn() }; - jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData }); - - clickInviteButton(); - }); - - it('calls Api groupShareWithGroup with the correct params', () => { - expect(Api.groupShareWithGroup).toHaveBeenCalledWith(id, groupPostData); - }); - - it('displays the successful toastMessage', () => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', { - onComplete: expect.any(Function), - }); - }); - }); - - describe('when sharing the group fails', () => { - beforeEach(() => { - createInviteGroupToGroupWrapper(); - - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ groupToBeSharedWith: sharedGroup }); - wrapper.vm.$toast = { show: jest.fn() }; - - jest - .spyOn(Api, 'groupShareWithGroup') - .mockRejectedValue({ response: { data: { success: false } } }); - - clickInviteButton(); - }); - - it('displays the generic error message', () => { - expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong'); - expect(membersFormGroupDescription()).toBe(''); - }); - }); - }); - describe('tracking', () => { - beforeEach(() => { - createComponent({ newUsersToInvite: [user3] }); + beforeEach(async () => { + createComponent(); + await triggerMembersTokenSelect([user3]); wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({}); diff --git a/spec/frontend/invite_members/components/invite_modal_base_spec.js b/spec/frontend/invite_members/components/invite_modal_base_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..4b183bfd670c6eb1ef39ede6fa3af3c749d4af86 --- /dev/null +++ b/spec/frontend/invite_members/components/invite_modal_base_spec.js @@ -0,0 +1,103 @@ +import { + GlDropdown, + GlDropdownItem, + GlDatepicker, + GlFormGroup, + GlSprintf, + GlLink, + GlModal, +} from '@gitlab/ui'; +import { stubComponent } from 'helpers/stub_component'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import InviteModalBase from '~/invite_members/components/invite_modal_base.vue'; +import { CANCEL_BUTTON_TEXT, INVITE_BUTTON_TEXT } from '~/invite_members/constants'; +import { propsData } from '../mock_data/modal_base'; + +describe('InviteModalBase', () => { + let wrapper; + + const createComponent = (data = {}, props = {}) => { + wrapper = shallowMountExtended(InviteModalBase, { + propsData: { + ...propsData, + ...props, + }, + data() { + return data; + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: + '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>', + }), + GlDropdown: true, + GlDropdownItem: true, + GlSprintf, + GlFormGroup: stubComponent(GlFormGroup, { + props: ['state', 'invalidFeedback', 'description'], + }), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem); + const findDatepicker = () => wrapper.findComponent(GlDatepicker); + const findLink = () => wrapper.findComponent(GlLink); + const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text(); + const findCancelButton = () => wrapper.findByTestId('cancel-button'); + const findInviteButton = () => wrapper.findByTestId('invite-button'); + + describe('rendering the modal', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the modal with the correct title', () => { + expect(wrapper.findComponent(GlModal).props('title')).toBe(propsData.modalTitle); + }); + + it('displays the introText', () => { + expect(findIntroText()).toBe(propsData.labelIntroText); + }); + + it('renders the Cancel button text correctly', () => { + expect(findCancelButton().text()).toBe(CANCEL_BUTTON_TEXT); + }); + + it('renders the Invite button text correctly', () => { + expect(findInviteButton().text()).toBe(INVITE_BUTTON_TEXT); + }); + + it('renders the Invite button modal without isLoading', () => { + expect(findInviteButton().props('loading')).toBe(false); + }); + + describe('rendering the access levels dropdown', () => { + it('sets the default dropdown text to the default access level name', () => { + expect(findDropdown().attributes('text')).toBe('Guest'); + }); + + it('renders dropdown items for each accessLevel', () => { + expect(findDropdownItems()).toHaveLength(5); + }); + }); + + describe('rendering the help link', () => { + it('renders the correct link', () => { + expect(findLink().attributes('href')).toBe(propsData.helpLink); + }); + }); + + describe('rendering the access expiration date field', () => { + it('renders the datepicker', () => { + expect(findDatepicker().exists()).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/invite_members/mock_data/group_modal.js b/spec/frontend/invite_members/mock_data/group_modal.js new file mode 100644 index 0000000000000000000000000000000000000000..c05c4edb7d04da65912d8cd6066d9e47a87c49be --- /dev/null +++ b/spec/frontend/invite_members/mock_data/group_modal.js @@ -0,0 +1,11 @@ +export const propsData = { + id: '1', + name: 'test name', + isProject: false, + invalidGroups: [], + accessLevels: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }, + defaultAccessLevel: 10, + helpLink: 'https://example.com', +}; + +export const sharedGroup = { id: '981' }; diff --git a/spec/frontend/invite_members/mock_data/member_modal.js b/spec/frontend/invite_members/mock_data/member_modal.js new file mode 100644 index 0000000000000000000000000000000000000000..590502909b2dc054f70a49b1bc346651b3045833 --- /dev/null +++ b/spec/frontend/invite_members/mock_data/member_modal.js @@ -0,0 +1,36 @@ +export const propsData = { + id: '1', + name: 'test name', + isProject: false, + accessLevels: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }, + defaultAccessLevel: 30, + helpLink: 'https://example.com', + tasksToBeDoneOptions: [ + { text: 'First task', value: 'first' }, + { text: 'Second task', value: 'second' }, + ], + projects: [ + { text: 'First project', value: '1' }, + { text: 'Second project', value: '2' }, + ], +}; + +export const inviteSource = 'unknown'; +export const newProjectPath = 'projects/new'; + +export const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' }; +export const user2 = { id: 2, name: 'Name Two', username: 'one_2', avatar_url: '' }; +export const user3 = { + id: 'user-defined-token', + name: 'email@example.com', + username: 'one_2', + avatar_url: '', +}; +export const user4 = { + id: 'user-defined-token', + name: 'email4@example.com', + username: 'one_4', + avatar_url: '', +}; + +export const GlEmoji = { template: '<img/>' }; diff --git a/spec/frontend/invite_members/mock_data/modal_base.js b/spec/frontend/invite_members/mock_data/modal_base.js new file mode 100644 index 0000000000000000000000000000000000000000..ea5a8d2b00d56b42e241f4ebf052228e41b564ad --- /dev/null +++ b/spec/frontend/invite_members/mock_data/modal_base.js @@ -0,0 +1,11 @@ +export const propsData = { + modalTitle: '_modal_title_', + modalId: '_modal_id_', + name: '_name_', + accessLevels: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }, + defaultAccessLevel: 10, + helpLink: 'https://example.com', + labelIntroText: '_label_intro_text_', + labelSearchField: '_label_search_field_', + formGroupDescription: '_form_group_description_', +}; diff --git a/spec/helpers/invite_members_helper_spec.rb b/spec/helpers/invite_members_helper_spec.rb index c032c7875a91933a9dd70db03736e2b4a60ddc4d..6a854a6592088334de2b4934d86d2d9f58b2053d 100644 --- a/spec/helpers/invite_members_helper_spec.rb +++ b/spec/helpers/invite_members_helper_spec.rb @@ -15,13 +15,28 @@ helper.extend(Gitlab::Experimentation::ControllerConcern) end - describe '#common_invite_modal_dataset' do + describe '#common_invite_group_modal_data' do it 'has expected common attributes' do attributes = { id: project.id, name: project.name, default_access_level: Gitlab::Access::GUEST, - invalid_groups: project.related_group_ids + invalid_groups: project.related_group_ids, + help_link: help_page_url('user/permissions'), + is_project: 'true', + access_levels: ProjectMember.access_level_roles.to_json + } + + expect(helper.common_invite_group_modal_data(project, ProjectMember, 'true')).to include(attributes) + end + end + + describe '#common_invite_modal_dataset' do + it 'has expected common attributes' do + attributes = { + id: project.id, + name: project.name, + default_access_level: Gitlab::Access::GUEST } expect(helper.common_invite_modal_dataset(project)).to include(attributes) diff --git a/spec/support/helpers/features/invite_members_modal_helper.rb b/spec/support/helpers/features/invite_members_modal_helper.rb index 11040562b494698e28d01a45330a2d1155ac8efd..2a4f78ca57f17ea575771b0f3aa5bdf97efaff6b 100644 --- a/spec/support/helpers/features/invite_members_modal_helper.rb +++ b/spec/support/helpers/features/invite_members_modal_helper.rb @@ -8,7 +8,7 @@ module InviteMembersModalHelper def invite_member(name, role: 'Guest', expires_at: nil) click_on 'Invite members' - page.within '[data-testid="invite-members-modal"]' do + page.within '[data-testid="invite-modal"]' do find('[data-testid="members-token-select-input"]').set(name) wait_for_requests