diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js index bc83844b8b9c7707560a155a47f58856a1d6e4c6..b003302ec8e5cb7107a8b9c4aac32fc254fc85f2 100644 --- a/app/assets/javascripts/persistent_user_callout.js +++ b/app/assets/javascripts/persistent_user_callout.js @@ -7,10 +7,11 @@ const DEFERRED_LINK_CLASS = 'deferred-link'; export default class PersistentUserCallout { constructor(container, options = container.dataset) { - const { dismissEndpoint, featureId, deferLinks } = options; + const { dismissEndpoint, featureId, groupId, deferLinks } = options; this.container = container; this.dismissEndpoint = dismissEndpoint; this.featureId = featureId; + this.groupId = groupId; this.deferLinks = parseBoolean(deferLinks); this.init(); @@ -52,6 +53,7 @@ export default class PersistentUserCallout { axios .post(this.dismissEndpoint, { feature_name: this.featureId, + group_id: this.groupId, }) .then(() => { this.container.remove(); diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index a7f8704b55977833c6960002dd0646634ff3980a..337c204c36ab3df06ca6348acadb14e3d573906a 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -10,6 +10,7 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-new-user-signups-cap-reached', '.js-eoa-bronze-plan-banner', '.js-security-newsletter-callout', + '.js-approaching-seats-count-threshold', ]; const initCallouts = () => { diff --git a/app/helpers/users/group_callouts_helper.rb b/app/helpers/users/group_callouts_helper.rb index b66c7f9f8218ce19e6f65e1f7be47f1b20f2626d..0aa4eb89499a2a5ff830b6f72bd145b7eaff3aa1 100644 --- a/app/helpers/users/group_callouts_helper.rb +++ b/app/helpers/users/group_callouts_helper.rb @@ -3,6 +3,7 @@ module Users module GroupCalloutsHelper INVITE_MEMBERS_BANNER = 'invite_members_banner' + APPROACHING_SEAT_COUNT_THRESHOLD = 'approaching_seat_count_threshold' def show_invite_banner?(group) Ability.allowed?(current_user, :admin_group, group) && diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb index da9b95fd718bbcd4bbada7591d3740d0ef44e17d..faa5130e6ec2a8344fcd12ee38421d112b3cf237 100644 --- a/app/models/users/group_callout.rb +++ b/app/models/users/group_callout.rb @@ -9,7 +9,8 @@ class GroupCallout < ApplicationRecord belongs_to :group enum feature_name: { - invite_members_banner: 1 + invite_members_banner: 1, + approaching_seat_count_threshold: 2 # EE-only } validates :group, presence: true diff --git a/ee/app/helpers/seats_count_alert_helper.rb b/ee/app/helpers/seats_count_alert_helper.rb index 33b42eeb7d67d2ae90f45696ddfb96a3ce724c75..c088cbb2a530c52488f42b9df4a1543f3259ee33 100644 --- a/ee/app/helpers/seats_count_alert_helper.rb +++ b/ee/app/helpers/seats_count_alert_helper.rb @@ -26,9 +26,8 @@ def seats_usage_link end def show_seats_count_alert? - return false unless root_namespace&.group_namespace? - return false unless root_namespace&.has_owner?(current_user) - return false unless current_subscription + return false unless ::Gitlab.dev_env_or_com? && group_with_owner? && current_subscription + return false if user_dismissed_alert? !!@display_seats_count_alert end @@ -39,6 +38,22 @@ def total_seats_count private + def user_dismissed_alert? + current_user.dismissed_callout_for_group?( + feature_name: Users::GroupCalloutsHelper::APPROACHING_SEAT_COUNT_THRESHOLD, + group: root_namespace, + ignore_dismissal_earlier_than: last_member_added_at + ) + end + + def last_member_added_at + root_namespace&.last_billed_user_created_at + end + + def group_with_owner? + root_namespace&.group_namespace? && root_namespace&.has_owner?(current_user) + end + def root_namespace @project&.root_ancestor || @group&.root_ancestor end diff --git a/ee/app/models/ee/group.rb b/ee/app/models/ee/group.rb index e2f93bf583b121539926e9aa8ce1defe311eb9b5..ae0d98ee5a17540a6982bed4f8b9c8e00043fa07 100644 --- a/ee/app/models/ee/group.rb +++ b/ee/app/models/ee/group.rb @@ -462,6 +462,10 @@ def access_level_roles levels.merge(::Gitlab::Access::MINIMAL_ACCESS_HASH) end + def last_billed_user_created_at + billed_group_and_projects_members.reverse_order.limit(1).pluck(:created_at).first + end + override :users_count def users_count return all_group_members.count if minimal_access_role_allowed? @@ -610,6 +614,15 @@ def billed_user_ids_including_guests end end + def billed_group_and_projects_members + ::Member + .in_hierarchy(self) + .active + .non_guests + .non_invite + .order(:created_at) + end + # Members belonging directly to Group or its subgroups def billed_group_users(non_guests: false) members = ::GroupMember.active_without_invites_and_requests.where( diff --git a/ee/app/views/layouts/header/_seats_count_alert.html.haml b/ee/app/views/layouts/header/_seats_count_alert.html.haml index 3bd36b43a296d16a5e8e49e418d35df78db52816..9229f9b95474af7c572e56c6f901f2168d40218c 100644 --- a/ee/app/views/layouts/header/_seats_count_alert.html.haml +++ b/ee/app/views/layouts/header/_seats_count_alert.html.haml @@ -1,8 +1,10 @@ - return unless show_seats_count_alert? .container.container-limited.pt-3 - .gl-alert.gl-alert-info{ role: 'alert' } + .gl-alert.gl-alert-info.js-approaching-seats-count-threshold{ role: 'alert', data: { dismiss_endpoint: group_callouts_path, + feature_id: Users::GroupCalloutsHelper::APPROACHING_SEAT_COUNT_THRESHOLD, + group_id: root_namespace.id } } = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon') - %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } + %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss'), data: { testid: 'approaching-seats-count-threshold-alert-dismiss' } } = sprite_icon('close', size: 16, css_class: 'gl-icon') .gl-alert-body %h4.gl-alert-title= _('%{group_name} is approaching the limit of available seats') % { group_name: group_name } diff --git a/ee/spec/features/gitlab_subscriptions/seats_count_alert_spec.rb b/ee/spec/features/gitlab_subscriptions/seats_count_alert_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d10a89a43d35f30694fe6c7288eb1577400a5ed1 --- /dev/null +++ b/ee/spec/features/gitlab_subscriptions/seats_count_alert_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Display approaching seats count threshold alert', :saas, :js do + let_it_be(:user) { create(:user) } + + shared_examples_for 'a hidden alert' do + it 'does not show the alert' do + visit visit_path + + expect(page).not_to have_content("#{group.name} is approaching the limit of available seats") + expect(page).not_to have_link('View seat usage', href: usage_quotas_path(group, anchor: 'seats-quota-tab')) + end + end + + shared_examples_for 'a visible alert' do + it 'shows the alert' do + visit visit_path + + expect(page).to have_content("#{group.name} is approaching the limit of available seats") + expect(page).to have_content("Your subscription has #{gitlab_subscription.seats - gitlab_subscription.seats_in_use} out of #{gitlab_subscription.seats} seats remaining. Even if you reach the number of seats in your subscription, you can continue to add users, and GitLab will bill you for the overage.") + expect(page).to have_link('View seat usage', href: usage_quotas_path(group, anchor: 'seats-quota-tab')) + end + end + + shared_examples_for 'a dismissed alert' do + context 'when alert was dismissed' do + before do + visit visit_path + + find('body.page-initialised [data-testid="approaching-seats-count-threshold-alert-dismiss"]').click + end + + it_behaves_like 'a hidden alert' + end + end + + context 'when conditions not met' do + let_it_be(:group) { create(:group) } + let_it_be(:visit_path) { group_path(group) } + + context 'when logged out' do + it_behaves_like 'a hidden alert' + end + + context 'when logged in owner' do + before do + group.add_owner(user) + sign_in(user) + end + + it_behaves_like 'a hidden alert' + end + end +end diff --git a/ee/spec/models/ee/group_spec.rb b/ee/spec/models/ee/group_spec.rb index 899ef6f185f7b42739a5c3920a4ce6405b8bf08f..84dd65d9ad177cde071b372eebbb005be04a92f6 100644 --- a/ee/spec/models/ee/group_spec.rb +++ b/ee/spec/models/ee/group_spec.rb @@ -823,6 +823,39 @@ end end + describe '#last_billed_user_created_at' do + subject(:last_billed) { group.last_billed_user_created_at } + + let(:group) { create(:group) } + let(:user) { create(:user) } + + context 'without billed users' do + it { is_expected.to be nil } + end + + context 'with guest users' do + before do + create(:group_member, :guest, user: user, source: group) + end + + it { is_expected.to be nil } + end + + context 'with billed users' do + let_it_be(:expected_time) { Time.new(2022, 4, 19, 00, 00, 00, '+00:00') } + + before do + create(:group_member, user: create(:user), source: group, created_at: expected_time) + create(:group_member, :guest, user: user, source: group, created_at: '2022-07-02') + create(:group_member, user: create(:user), source: group, created_at: '2022-03-16') + end + + it 'returns the last added billed member' do + expect(last_billed).to be_like_time(expected_time) + end + end + end + describe '#saml_discovery_token' do it 'returns existing tokens' do group = create(:group, saml_discovery_token: 'existing') diff --git a/spec/frontend/persistent_user_callout_spec.js b/spec/frontend/persistent_user_callout_spec.js index 1db255106ed1eaae01914540682fa5f07f2c6c39..4633602de262ee43649118e1a109e3bb71160591 100644 --- a/spec/frontend/persistent_user_callout_spec.js +++ b/spec/frontend/persistent_user_callout_spec.js @@ -10,6 +10,7 @@ jest.mock('~/flash'); describe('PersistentUserCallout', () => { const dismissEndpoint = '/dismiss'; const featureName = 'feature'; + const groupId = '5'; function createFixture() { const fixture = document.createElement('div'); @@ -18,6 +19,7 @@ describe('PersistentUserCallout', () => { class="container" data-dismiss-endpoint="${dismissEndpoint}" data-feature-id="${featureName}" + data-group-id="${groupId}" > @@ -86,7 +88,9 @@ describe('PersistentUserCallout', () => { return waitForPromises().then(() => { expect(persistentUserCallout.container.remove).toHaveBeenCalled(); - expect(mockAxios.history.post[0].data).toBe(JSON.stringify({ feature_name: featureName })); + expect(mockAxios.history.post[0].data).toBe( + JSON.stringify({ feature_name: featureName, group_id: groupId }), + ); }); }); @@ -191,8 +195,8 @@ describe('PersistentUserCallout', () => { return waitForPromises().then(() => { expect(window.location.assign).toBeCalledWith(href); - expect(mockAxios.history.post[0].data).toBe(JSON.stringify({ feature_name: featureName })); expect(persistentUserCallout.container.remove).not.toHaveBeenCalled(); + expect(mockAxios.history.post[0].data).toBe(JSON.stringify({ feature_name: featureName })); }); });