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