diff options
Diffstat (limited to 'app/assets/javascripts/invite_members')
10 files changed, 649 insertions, 382 deletions
diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue index 216078ed35e..04a8ec3400f 100644 --- a/app/assets/javascripts/invite_members/components/group_select.vue +++ b/app/assets/javascripts/invite_members/components/group_select.vue @@ -24,6 +24,10 @@ export default { prop: 'selectedGroup', }, props: { + accessLevels: { + type: Object, + required: true, + }, groupsFilter: { type: String, required: false, @@ -34,6 +38,10 @@ export default { required: false, default: null, }, + invalidGroups: { + type: Array, + required: true, + }, }, data() { return { @@ -50,6 +58,13 @@ export default { isFetchResultEmpty() { return this.groups.length === 0; }, + defaultFetchOptions() { + return { + exclude_internal: true, + active: true, + min_access_level: this.accessLevels.Guest, + }; + }, }, watch: { searchTerm() { @@ -64,18 +79,26 @@ export default { this.isFetching = true; return this.fetchGroups() .then((response) => { - this.groups = response.map((group) => ({ - id: group.id, - name: group.full_name, - path: group.path, - avatarUrl: group.avatar_url, - })); + this.groups = this.processGroups(response); this.isFetching = false; }) .catch(() => { this.isFetching = false; }); }, SEARCH_DELAY), + processGroups(response) { + const rawGroups = response.map((group) => ({ + id: group.id, + name: group.full_name, + path: group.path, + avatarUrl: group.avatar_url, + })); + + return this.filterOutInvalidGroups(rawGroups); + }, + filterOutInvalidGroups(groups) { + return groups.filter((group) => this.invalidGroups.indexOf(group.id) === -1); + }, selectGroup(group) { this.selectedGroup = group; @@ -84,13 +107,9 @@ export default { fetchGroups() { switch (this.groupsFilter) { case GROUP_FILTERS.DESCENDANT_GROUPS: - return getDescendentGroups( - this.parentGroupId, - this.searchTerm, - this.$options.defaultFetchOptions, - ); + return getDescendentGroups(this.parentGroupId, this.searchTerm, this.defaultFetchOptions); default: - return getGroups(this.searchTerm, this.$options.defaultFetchOptions); + return getGroups(this.searchTerm, this.defaultFetchOptions); } }, }, @@ -99,10 +118,6 @@ export default { searchPlaceholder: s__('GroupSelect|Search groups'), emptySearchResult: s__('GroupSelect|No matching results'), }, - defaultFetchOptions: { - exclude_internal: true, - active: true, - }, }; </script> <template> 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 c9de078319a..c08a4d75c59 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 00000000000..6598000c464 --- /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 91a139a5105..6c0fc5caf26 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 InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue'; 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 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, @@ -110,73 +84,31 @@ export default { }, 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 ( @@ -215,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); } }, @@ -231,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 !== '') { @@ -304,188 +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" - :groups-filter="groupSelectFilter" - :parent-group-id="groupSelectParentId" - @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 @@ -495,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" @@ -522,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 }} @@ -531,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_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue index 7dd74f8803a..79b192e2495 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue @@ -71,7 +71,7 @@ export default { return this.triggerElement === targetTriggerElement; }, openModal() { - eventHub.$emit('openModal', { inviteeType: 'members', source: this.triggerSource }); + eventHub.$emit('openModal', { source: this.triggerSource }); }, }, TRIGGER_ELEMENT_BUTTON, 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 00000000000..fc00f5b9343 --- /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 ec59b3909fe..cf2ee508184 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 00000000000..be1576ad0b0 --- /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 2cc056f2ddb..e9d620cedf0 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -28,6 +28,7 @@ export default function initInviteMembersModal() { return new Vue({ el, + name: 'InviteMembersModalRoot', provide: { newProjectPath: el.dataset.newProjectPath, }, @@ -38,8 +39,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), tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'), projects: JSON.parse(el.dataset.projects || '[]'), usersFilter: el.dataset.usersFilter, diff --git a/app/assets/javascripts/invite_members/init_invite_members_trigger.js b/app/assets/javascripts/invite_members/init_invite_members_trigger.js index 935edb35349..54a5eab2e4b 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_trigger.js +++ b/app/assets/javascripts/invite_members/init_invite_members_trigger.js @@ -11,6 +11,7 @@ export default function initInviteMembersTrigger() { return triggers.forEach((el) => { return new Vue({ el, + name: 'InviteMembersTriggerRoot', render: (createElement) => createElement(InviteMembersTrigger, { props: { |