Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/invite_members')
-rw-r--r--app/assets/javascripts/invite_members/components/group_select.vue47
-rw-r--r--app/assets/javascripts/invite_members/components/invite_group_trigger.vue2
-rw-r--r--app/assets/javascripts/invite_members/components/invite_groups_modal.vue146
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue419
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_trigger.vue2
-rw-r--r--app/assets/javascripts/invite_members/components/invite_modal_base.vue276
-rw-r--r--app/assets/javascripts/invite_members/constants.js91
-rw-r--r--app/assets/javascripts/invite_members/init_invite_groups_modal.js44
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_modal.js3
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_trigger.js1
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: {