From 801c1f172b6e5e669bd31a565d79ff7eba2d27e0 Mon Sep 17 00:00:00 2001
From: Serhii Yarynovskyi <syarynovskyi@gitlab.com>
Date: Wed, 30 Mar 2022 10:07:44 +0000
Subject: [PATCH] Add user limit notification for invite members modal

Now when invite members modal appears, customer can see indication
how many seats are remaining when namespace approaches user limit

Changelog: added
---
 .../components/invite_members_modal.vue       |  7 ++
 .../components/invite_modal_base.vue          |  2 +
 .../components/user_limit_notification.vue    | 97 +++++++++++++++++++
 .../init_invite_members_modal.js              |  5 +
 app/helpers/invite_members_helper.rb          |  1 +
 ee/app/helpers/ee/invite_members_helper.rb    | 16 +++
 .../helpers/ee/invite_members_helper_spec.rb  | 49 +++++++++-
 locale/gitlab.pot                             | 17 ++++
 .../user_limit_notification_spec.js           | 71 ++++++++++++++
 9 files changed, 264 insertions(+), 1 deletion(-)
 create mode 100644 app/assets/javascripts/invite_members/components/user_limit_notification.vue
 create mode 100644 spec/frontend/invite_members/components/user_limit_notification_spec.js

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 da0c7860932c7..23225869636a7 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -24,6 +24,7 @@ import { responseMessageFromSuccess } from '../utils/response_message_parser';
 import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message';
 import ModalConfetti from './confetti.vue';
 import MembersTokenSelect from './members_token_select.vue';
+import UserLimitNotification from './user_limit_notification.vue';
 
 export default {
   name: 'InviteMembersModal',
@@ -37,6 +38,7 @@ export default {
     InviteModalBase,
     MembersTokenSelect,
     ModalConfetti,
+    UserLimitNotification,
   },
   inject: ['newProjectPath'],
   props: {
@@ -308,6 +310,11 @@ export default {
       <span v-if="isCelebration">{{ $options.labels.modal.celebrate.intro }} </span>
       <modal-confetti v-if="isCelebration" />
     </template>
+
+    <template #user-limit-notification>
+      <user-limit-notification />
+    </template>
+
     <template #select="{ validationState, labelId }">
       <members-token-select
         v-model="newUsersToInvite"
diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
index bafbe94b8bd76..43cd889c9b3d5 100644
--- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue
+++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
@@ -215,6 +215,8 @@ export default {
           <slot name="intro-text-after"></slot>
         </div>
 
+        <slot name="user-limit-notification"></slot>
+
         <gl-form-group
           :invalid-feedback="invalidFeedbackMessage"
           :state="validationState"
diff --git a/app/assets/javascripts/invite_members/components/user_limit_notification.vue b/app/assets/javascripts/invite_members/components/user_limit_notification.vue
new file mode 100644
index 0000000000000..beef1aef8a1ac
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/user_limit_notification.vue
@@ -0,0 +1,97 @@
+<script>
+import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import { s__, n__, sprintf } from '~/locale';
+
+const CLOSE_TO_LIMIT_COUNT = 2;
+
+const WARNING_ALERT_TITLE = s__(
+  'InviteMembersModal|You only have space for %{count} more %{members} in %{name}',
+);
+
+const DANGER_ALERT_TITLE = s__(
+  "InviteMembersModal|You've reached your %{count} %{members} limit for %{name}",
+);
+
+const CLOSE_TO_LIMIT_MESSAGE = s__(
+  'InviteMembersModal|To get more members an owner of this namespace can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.',
+);
+
+const REACHED_LIMIT_MESSAGE = s__(
+  'InviteMembersModal|New members will be unable to participate. You can manage your members by removing ones you no longer need.',
+).concat(' ', CLOSE_TO_LIMIT_MESSAGE);
+
+export default {
+  name: 'UserLimitNotification',
+  components: { GlAlert, GlSprintf, GlLink },
+  inject: ['name', 'newTrialRegistrationPath', 'purchasePath', 'freeUsersLimit', 'membersCount'],
+  computed: {
+    reachedLimit() {
+      return this.isLimit();
+    },
+    closeToLimit() {
+      return this.isLimit(CLOSE_TO_LIMIT_COUNT);
+    },
+    warningAlertTitle() {
+      return sprintf(WARNING_ALERT_TITLE, {
+        count: this.freeUsersLimit - this.membersCount,
+        members: this.pluralMembers(this.freeUsersLimit - this.membersCount),
+        name: this.name,
+      });
+    },
+    dangerAlertTitle() {
+      return sprintf(DANGER_ALERT_TITLE, {
+        count: this.freeUsersLimit,
+        members: this.pluralMembers(this.freeUsersLimit),
+        name: this.name,
+      });
+    },
+    variant() {
+      return this.reachedLimit ? 'danger' : 'warning';
+    },
+    title() {
+      return this.reachedLimit ? this.dangerAlertTitle : this.warningAlertTitle;
+    },
+    message() {
+      if (this.reachedLimit) {
+        return this.$options.i18n.reachedLimitMessage;
+      }
+
+      return this.$options.i18n.closeToLimitMessage;
+    },
+  },
+  methods: {
+    isLimit(deviation = 0) {
+      if (this.freeUsersLimit && this.membersCount) {
+        return this.membersCount >= this.freeUsersLimit - deviation;
+      }
+
+      return false;
+    },
+    pluralMembers(count) {
+      return n__('member', 'members', count);
+    },
+  },
+  i18n: {
+    reachedLimitMessage: REACHED_LIMIT_MESSAGE,
+    closeToLimitMessage: CLOSE_TO_LIMIT_MESSAGE,
+  },
+};
+</script>
+
+<template>
+  <gl-alert
+    v-if="reachedLimit || closeToLimit"
+    :variant="variant"
+    :dismissible="false"
+    :title="title"
+  >
+    <gl-sprintf :message="message">
+      <template #trialLink="{ content }">
+        <gl-link :href="newTrialRegistrationPath" class="gl-label-link">{{ content }}</gl-link>
+      </template>
+      <template #upgradeLink="{ content }">
+        <gl-link :href="purchasePath" class="gl-label-link">{{ content }}</gl-link>
+      </template>
+    </gl-sprintf>
+  </gl-alert>
+</template>
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 cb05798bb9ddf..958121ad735a2 100644
--- a/app/assets/javascripts/invite_members/init_invite_members_modal.js
+++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js
@@ -24,7 +24,12 @@ export default (function initInviteMembersModal() {
         el,
         name: 'InviteMembersModalRoot',
         provide: {
+          name: el.dataset.name,
           newProjectPath: el.dataset.newProjectPath,
+          newTrialRegistrationPath: el.dataset.newTrialRegistrationPath,
+          purchasePath: el.dataset.purchasePath,
+          freeUsersLimit: el.dataset.freeUsersLimit && parseInt(el.dataset.freeUsersLimit, 10),
+          membersCount: el.dataset.membersCount && parseInt(el.dataset.membersCount, 10),
         },
         render: (createElement) =>
           createElement(InviteMembersModal, {
diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb
index 885f6bac51064..a682d2712be57 100644
--- a/app/helpers/invite_members_helper.rb
+++ b/app/helpers/invite_members_helper.rb
@@ -46,6 +46,7 @@ def common_invite_group_modal_data(source, member_class, is_project)
     }
   end
 
+  # Overridden in EE
   def common_invite_modal_dataset(source)
     dataset = {
       id: source.id,
diff --git a/ee/app/helpers/ee/invite_members_helper.rb b/ee/app/helpers/ee/invite_members_helper.rb
index e8b154783ab0b..23d0010fdf436 100644
--- a/ee/app/helpers/ee/invite_members_helper.rb
+++ b/ee/app/helpers/ee/invite_members_helper.rb
@@ -4,6 +4,22 @@ module EE
   module InviteMembersHelper
     extend ::Gitlab::Utils::Override
 
+    override :common_invite_modal_dataset
+    def common_invite_modal_dataset(source)
+      dataset = super
+
+      if source.root_ancestor.apply_free_user_cap? && !source.root_ancestor.user_namespace?
+        dataset.merge({
+          new_trial_registration_path: new_trial_path,
+          purchase_path: group_billings_path(source.root_ancestor),
+          free_users_limit: ::Plan::FREE_USER_LIMIT,
+          members_count: source.root_ancestor.free_plan_members_count
+        })
+      else
+        dataset
+      end
+    end
+
     override :users_filter_data
     def users_filter_data(group)
       root_group = group&.root_ancestor
diff --git a/ee/spec/helpers/ee/invite_members_helper_spec.rb b/ee/spec/helpers/ee/invite_members_helper_spec.rb
index fd2724259e121..2f5f4e8a80993 100644
--- a/ee/spec/helpers/ee/invite_members_helper_spec.rb
+++ b/ee/spec/helpers/ee/invite_members_helper_spec.rb
@@ -2,7 +2,54 @@
 require 'spec_helper'
 
 RSpec.describe EE::InviteMembersHelper do
-  describe '.users_filter_data' do
+  include Devise::Test::ControllerHelpers
+
+  describe '#common_invite_modal_dataset', :saas do
+    let(:project) { build(:project) }
+
+    let(:notification_attributes) do
+      {
+        free_users_limit: 5,
+        members_count: 0,
+        new_trial_registration_path: '/-/trials/new',
+        purchase_path: "/groups/#{project.root_ancestor.path}/-/billings"
+      }
+    end
+
+    context 'when applying the free user cap is not valid' do
+      let!(:group) do
+        build(:group, projects: [project], gitlab_subscription: build(:gitlab_subscription, :default))
+      end
+
+      it 'does not include users limit notification data' do
+        expect(helper.common_invite_modal_dataset(project)).not_to include(notification_attributes)
+      end
+    end
+
+    context 'when applying the free user cap is valid' do
+      context 'when user namespace' do
+        let!(:user_namespace) do
+          build(:user_namespace, projects: [project], gitlab_subscription: build(:gitlab_subscription, :free))
+        end
+
+        it 'does not include users limit notification data' do
+          expect(helper.common_invite_modal_dataset(project)).not_to include(notification_attributes)
+        end
+      end
+
+      context 'when group namespace' do
+        let!(:group) do
+          build(:group, projects: [project], gitlab_subscription: build(:gitlab_subscription, :free))
+        end
+
+        it 'includes users limit notification data' do
+          expect(helper.common_invite_modal_dataset(project)).to include(notification_attributes)
+        end
+      end
+    end
+  end
+
+  describe '#users_filter_data' do
     let_it_be(:group) { create(:group) }
     let_it_be(:saml_provider) { create(:saml_provider, group: group) }
 
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index d85a2767f068d..94cde6e6b10ea 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -20542,6 +20542,9 @@ msgstr ""
 msgid "InviteMembersModal|Members were successfully added"
 msgstr ""
 
+msgid "InviteMembersModal|New members will be unable to participate. You can manage your members by removing ones you no longer need."
+msgstr ""
+
 msgid "InviteMembersModal|Search for a group to invite"
 msgstr ""
 
@@ -20560,6 +20563,12 @@ msgstr ""
 msgid "InviteMembersModal|To assign issues to a new team member, you need a project for the issues. %{linkStart}Create a project to get started.%{linkEnd}"
 msgstr ""
 
+msgid "InviteMembersModal|To get more members an owner of this namespace can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier."
+msgstr ""
+
+msgid "InviteMembersModal|You only have space for %{count} more %{members} in %{name}"
+msgstr ""
+
 msgid "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} group."
 msgstr ""
 
@@ -20572,6 +20581,9 @@ msgstr ""
 msgid "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} project."
 msgstr ""
 
+msgid "InviteMembersModal|You've reached your %{count} %{members} limit for %{name}"
+msgstr ""
+
 msgid "InviteMembers|Invite a group"
 msgstr ""
 
@@ -44459,6 +44471,11 @@ msgstr ""
 msgid "math|There was an error rendering this math block"
 msgstr ""
 
+msgid "member"
+msgid_plural "members"
+msgstr[0] ""
+msgstr[1] ""
+
 msgid "merge request"
 msgid_plural "merge requests"
 msgstr[0] ""
diff --git a/spec/frontend/invite_members/components/user_limit_notification_spec.js b/spec/frontend/invite_members/components/user_limit_notification_spec.js
new file mode 100644
index 0000000000000..c779cf2ee3f9b
--- /dev/null
+++ b/spec/frontend/invite_members/components/user_limit_notification_spec.js
@@ -0,0 +1,71 @@
+import { GlAlert, GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import UserLimitNotification from '~/invite_members/components/user_limit_notification.vue';
+
+describe('UserLimitNotification', () => {
+  let wrapper;
+
+  const findAlert = () => wrapper.findComponent(GlAlert);
+
+  const createComponent = (providers = {}) => {
+    wrapper = shallowMountExtended(UserLimitNotification, {
+      provide: {
+        name: 'my group',
+        newTrialRegistrationPath: 'newTrialRegistrationPath',
+        purchasePath: 'purchasePath',
+        freeUsersLimit: 5,
+        membersCount: 1,
+        ...providers,
+      },
+      stubs: { GlSprintf },
+    });
+  };
+
+  afterEach(() => {
+    wrapper.destroy();
+  });
+
+  describe('when limit is not reached', () => {
+    beforeEach(() => {
+      createComponent();
+    });
+
+    it('renders empty block', () => {
+      expect(findAlert().exists()).toBe(false);
+    });
+  });
+
+  describe('when close to limit', () => {
+    beforeEach(() => {
+      createComponent({ membersCount: 3 });
+    });
+
+    it("renders user's limit notification", () => {
+      const alert = findAlert();
+
+      expect(alert.attributes('title')).toEqual(
+        'You only have space for 2 more members in my group',
+      );
+
+      expect(alert.text()).toEqual(
+        'To get more members an owner of this namespace can start a trial or upgrade to a paid tier.',
+      );
+    });
+  });
+
+  describe('when limit is reached', () => {
+    beforeEach(() => {
+      createComponent({ membersCount: 5 });
+    });
+
+    it("renders user's limit notification", () => {
+      const alert = findAlert();
+
+      expect(alert.attributes('title')).toEqual("You've reached your 5 members limit for my group");
+
+      expect(alert.text()).toEqual(
+        'New members will be unable to participate. You can manage your members by removing ones you no longer need. To get more members an owner of this namespace can start a trial or upgrade to a paid tier.',
+      );
+    });
+  });
+});
-- 
GitLab