Commit b6279f98 authored by Angelo Gulina's avatar Angelo Gulina Committed by Kerri Miller

Permanent dismissal for the alert banner

When the Sears Count Alert is dismissed, the choice will be
remembered untill the next time seats are updated
parent b6a1aaa8
...@@ -7,10 +7,11 @@ const DEFERRED_LINK_CLASS = 'deferred-link'; ...@@ -7,10 +7,11 @@ const DEFERRED_LINK_CLASS = 'deferred-link';
export default class PersistentUserCallout { export default class PersistentUserCallout {
constructor(container, options = container.dataset) { constructor(container, options = container.dataset) {
const { dismissEndpoint, featureId, deferLinks } = options; const { dismissEndpoint, featureId, groupId, deferLinks } = options;
this.container = container; this.container = container;
this.dismissEndpoint = dismissEndpoint; this.dismissEndpoint = dismissEndpoint;
this.featureId = featureId; this.featureId = featureId;
this.groupId = groupId;
this.deferLinks = parseBoolean(deferLinks); this.deferLinks = parseBoolean(deferLinks);
this.init(); this.init();
...@@ -52,6 +53,7 @@ export default class PersistentUserCallout { ...@@ -52,6 +53,7 @@ export default class PersistentUserCallout {
axios axios
.post(this.dismissEndpoint, { .post(this.dismissEndpoint, {
feature_name: this.featureId, feature_name: this.featureId,
group_id: this.groupId,
}) })
.then(() => { .then(() => {
this.container.remove(); this.container.remove();
......
...@@ -10,6 +10,7 @@ const PERSISTENT_USER_CALLOUTS = [ ...@@ -10,6 +10,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-new-user-signups-cap-reached', '.js-new-user-signups-cap-reached',
'.js-eoa-bronze-plan-banner', '.js-eoa-bronze-plan-banner',
'.js-security-newsletter-callout', '.js-security-newsletter-callout',
'.js-approaching-seats-count-threshold',
]; ];
const initCallouts = () => { const initCallouts = () => {
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
module Users module Users
module GroupCalloutsHelper module GroupCalloutsHelper
INVITE_MEMBERS_BANNER = 'invite_members_banner' INVITE_MEMBERS_BANNER = 'invite_members_banner'
APPROACHING_SEAT_COUNT_THRESHOLD = 'approaching_seat_count_threshold'
def show_invite_banner?(group) def show_invite_banner?(group)
Ability.allowed?(current_user, :admin_group, group) && Ability.allowed?(current_user, :admin_group, group) &&
......
...@@ -9,7 +9,8 @@ module Users ...@@ -9,7 +9,8 @@ module Users
belongs_to :group belongs_to :group
enum feature_name: { enum feature_name: {
invite_members_banner: 1 invite_members_banner: 1,
approaching_seat_count_threshold: 2 # EE-only
} }
validates :group, presence: true validates :group, presence: true
......
...@@ -26,9 +26,8 @@ module SeatsCountAlertHelper ...@@ -26,9 +26,8 @@ module SeatsCountAlertHelper
end end
def show_seats_count_alert? def show_seats_count_alert?
return false unless root_namespace&.group_namespace? return false unless ::Gitlab.dev_env_or_com? && group_with_owner? && current_subscription
return false unless root_namespace&.has_owner?(current_user) return false if user_dismissed_alert?
return false unless current_subscription
!!@display_seats_count_alert !!@display_seats_count_alert
end end
...@@ -39,6 +38,22 @@ module SeatsCountAlertHelper ...@@ -39,6 +38,22 @@ module SeatsCountAlertHelper
private 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 def root_namespace
@project&.root_ancestor || @group&.root_ancestor @project&.root_ancestor || @group&.root_ancestor
end end
......
...@@ -462,6 +462,10 @@ module EE ...@@ -462,6 +462,10 @@ module EE
levels.merge(::Gitlab::Access::MINIMAL_ACCESS_HASH) levels.merge(::Gitlab::Access::MINIMAL_ACCESS_HASH)
end end
def last_billed_user_created_at
billed_group_and_projects_members.reverse_order.limit(1).pluck(:created_at).first
end
override :users_count override :users_count
def users_count def users_count
return all_group_members.count if minimal_access_role_allowed? return all_group_members.count if minimal_access_role_allowed?
...@@ -610,6 +614,15 @@ module EE ...@@ -610,6 +614,15 @@ module EE
end end
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 # Members belonging directly to Group or its subgroups
def billed_group_users(non_guests: false) def billed_group_users(non_guests: false)
members = ::GroupMember.active_without_invites_and_requests.where( members = ::GroupMember.active_without_invites_and_requests.where(
......
- return unless show_seats_count_alert? - return unless show_seats_count_alert?
.container.container-limited.pt-3 .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') = 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') = sprite_icon('close', size: 16, css_class: 'gl-icon')
.gl-alert-body .gl-alert-body
%h4.gl-alert-title= _('%{group_name} is approaching the limit of available seats') % { group_name: group_name } %h4.gl-alert-title= _('%{group_name} is approaching the limit of available seats') % { group_name: group_name }
......
# 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
...@@ -823,6 +823,39 @@ RSpec.describe Group do ...@@ -823,6 +823,39 @@ RSpec.describe Group do
end end
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 describe '#saml_discovery_token' do
it 'returns existing tokens' do it 'returns existing tokens' do
group = create(:group, saml_discovery_token: 'existing') group = create(:group, saml_discovery_token: 'existing')
......
...@@ -10,6 +10,7 @@ jest.mock('~/flash'); ...@@ -10,6 +10,7 @@ jest.mock('~/flash');
describe('PersistentUserCallout', () => { describe('PersistentUserCallout', () => {
const dismissEndpoint = '/dismiss'; const dismissEndpoint = '/dismiss';
const featureName = 'feature'; const featureName = 'feature';
const groupId = '5';
function createFixture() { function createFixture() {
const fixture = document.createElement('div'); const fixture = document.createElement('div');
...@@ -18,6 +19,7 @@ describe('PersistentUserCallout', () => { ...@@ -18,6 +19,7 @@ describe('PersistentUserCallout', () => {
class="container" class="container"
data-dismiss-endpoint="${dismissEndpoint}" data-dismiss-endpoint="${dismissEndpoint}"
data-feature-id="${featureName}" data-feature-id="${featureName}"
data-group-id="${groupId}"
> >
<button type="button" class="js-close"></button> <button type="button" class="js-close"></button>
</div> </div>
...@@ -86,7 +88,9 @@ describe('PersistentUserCallout', () => { ...@@ -86,7 +88,9 @@ describe('PersistentUserCallout', () => {
return waitForPromises().then(() => { return waitForPromises().then(() => {
expect(persistentUserCallout.container.remove).toHaveBeenCalled(); 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', () => { ...@@ -191,8 +195,8 @@ describe('PersistentUserCallout', () => {
return waitForPromises().then(() => { return waitForPromises().then(() => {
expect(window.location.assign).toBeCalledWith(href); 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(persistentUserCallout.container.remove).not.toHaveBeenCalled();
expect(mockAxios.history.post[0].data).toBe(JSON.stringify({ feature_name: featureName }));
}); });
}); });
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment