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