diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue index 504dbaed816562999ecdcdb34de403d2376d136e..3552e8170a7f9dbd0928c249ea395795ec682487 100644 --- a/app/assets/javascripts/super_sidebar/components/user_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue @@ -18,6 +18,7 @@ import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { logError } from '~/lib/logger'; +import UserMenuUpgradeSubscription from 'ee_component/super_sidebar/components/user_menu_upgrade_subscription.vue'; import { USER_MENU_TRACKING_DEFAULTS, DROPDOWN_Y_OFFSET } from '../constants'; import UserMenuProfileItem from './user_menu_profile_item.vue'; import UserMenuProjectStudioSection from './user_menu_project_studio_section.vue'; @@ -36,7 +37,7 @@ export default { buyPipelineMinutes: s__('CurrentUser|Buy compute minutes'), oneOfGroupsRunningOutOfPipelineMinutes: s__('CurrentUser|One of your groups is running out'), gitlabNext: s__('CurrentUser|Switch to GitLab Next'), - startTrial: s__('CurrentUser|Start an Ultimate trial'), + adminArea: s__('Navigation|Admin'), enterAdminMode: s__('CurrentUser|Enter Admin Mode'), leaveAdminMode: s__('CurrentUser|Leave Admin Mode'), @@ -53,6 +54,7 @@ export default { UserCounts, UserMenuProfileItem, UserMenuProjectStudioSection, + UserMenuUpgradeSubscription, SetStatusModal: () => import( /* webpackChunkName: 'statusModalBundle' */ '~/set_status_modal/set_status_modal_wrapper.vue' @@ -106,19 +108,7 @@ export default { }, }; }, - trialItem() { - return { - text: this.$options.i18n.startTrial, - href: this.data.trial.url, - extraAttrs: { - ...USER_MENU_TRACKING_DEFAULTS, - 'data-track-label': 'start_trial', - }, - }; - }, - showTrialItem() { - return this.data.trial?.has_start_trial; - }, + editProfileItem() { return { text: this.$options.i18n.editProfile, @@ -454,20 +444,9 @@ export default { - - - - + + +import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { USER_MENU_TRACKING_DEFAULTS } from '~/super_sidebar/constants'; + +export default { + name: 'UserMenuUpgradeSubscription', + i18n: { + upgradeSubscription: s__('CurrentUser|Upgrade subscription'), + }, + components: { + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, + GlIcon, + }, + props: { + data: { + required: true, + type: Object, + }, + }, + computed: { + upgradeSubscriptionItem() { + return { + text: this.$options.i18n.upgradeSubscription, + href: this.data.upgrade_url, + extraAttrs: { + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'upgrade_subscription', + }, + }; + }, + showUpgradeSubscriptionItem() { + return this.data.upgrade_url; + }, + }, +}; + + + diff --git a/ee/app/helpers/ee/sidebars_helper.rb b/ee/app/helpers/ee/sidebars_helper.rb index c767fec38a69e73b714814b0cd5ffd8f48d5d534..e96a4d35257fa45b10cf7015b94eda5490caebe4 100644 --- a/ee/app/helpers/ee/sidebars_helper.rb +++ b/ee/app/helpers/ee/sidebars_helper.rb @@ -39,14 +39,10 @@ def super_sidebar_context(user, group:, project:, panel:, panel_type:) context.merge!( GitlabSubscriptions::Trials::WidgetPresenter.new(root_namespace, user: current_user).attributes, - GitlabSubscriptions::Duo::AgentPlatformWidgetPresenter.new(user, context: project || group).attributes + GitlabSubscriptions::Duo::AgentPlatformWidgetPresenter.new(user, context: project || group).attributes, + GitlabSubscriptions::UpgradePresenter.new(user, namespace: root_namespace).attributes ) - context[:trial] = { - has_start_trial: trials_allowed?(user), - url: new_trial_path(glm_source: 'gitlab.com', glm_content: 'top-right-dropdown') - } - show_buy_pipeline_minutes = show_buy_pipeline_minutes?(project, group) return context unless show_buy_pipeline_minutes && root_namespace.present? diff --git a/ee/app/helpers/ee/users_helper.rb b/ee/app/helpers/ee/users_helper.rb index d5f7c8cc0d10be60fae20892facffbd48855aca4..169a4ce88d4483cdd086edd8894eb35b3c3a3c05 100644 --- a/ee/app/helpers/ee/users_helper.rb +++ b/ee/app/helpers/ee/users_helper.rb @@ -38,15 +38,6 @@ def user_badges_in_admin_section(user) end end - def trials_allowed?(user) - return false unless user - return false unless ::Gitlab::Saas.feature_available?(:subscriptions_trials) - - Rails.cache.fetch(['users', user.id, 'trials_allowed?'], expires_in: 10.minutes) do - !user.belongs_to_paid_namespace? && user.owns_group_without_trial? - end - end - def user_enterprise_group_text(user) enterprise_group = user.user_detail&.enterprise_group return unless enterprise_group diff --git a/ee/app/models/gitlab_subscriptions/trials.rb b/ee/app/models/gitlab_subscriptions/trials.rb index bedf559088d1af639e61641bcfec76c13c4a9e54..017a6acaddb8ebe2df249704dece93b661c78453 100644 --- a/ee/app/models/gitlab_subscriptions/trials.rb +++ b/ee/app/models/gitlab_subscriptions/trials.rb @@ -40,6 +40,12 @@ def self.eligible_namespaces_for_user(user) Namespaces::TrialEligibleFinder.new(user:).execute end + def self.owned_free_or_trial_groups(user) + Rails.cache.fetch(['users', user.id, 'owned_free_or_trial_groups'], expires_in: 10.minutes) do + user.owned_groups.free_or_trial + end + end + def self.namespace_add_on_eligible?(namespace) Namespaces::TrialEligibleFinder.new(namespace:).execute.any? end diff --git a/ee/app/presenters/gitlab_subscriptions/upgrade_presenter.rb b/ee/app/presenters/gitlab_subscriptions/upgrade_presenter.rb new file mode 100644 index 0000000000000000000000000000000000000000..4e58fcb2b2cf4b18b929126ff489427905f28fc2 --- /dev/null +++ b/ee/app/presenters/gitlab_subscriptions/upgrade_presenter.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module GitlabSubscriptions + class UpgradePresenter < Gitlab::View::Presenter::Simple + def initialize(user, namespace: nil) + @mediator = build_mediator(user, namespace) + end + + delegate :attributes, to: :mediator + + private + + attr_reader :mediator + + def build_mediator(user, namespace) + return SelfManagedMediator.new unless ::Gitlab::Saas.feature_available?(:gitlab_com_subscriptions) + + if namespace.present? + NamespaceMediator.new(user, namespace) + else + GlobalMediator.new(user) + end + end + + class SelfManagedMediator + def attributes + {} + end + end + + class NamespaceMediator + def initialize(user, namespace) + @user = user + @namespace = namespace + end + + def attributes + return {} unless eligible_for_upgrade? + + { upgrade_url: ::Gitlab::Routing.url_helpers.group_billings_path(namespace) } + end + + private + + attr_reader :user, :namespace + + def eligible_for_upgrade? + (!namespace.paid? || namespace.trial?) && Ability.allowed?(user, :edit_billing, namespace) + end + end + + class GlobalMediator + def initialize(user) + @user = user + end + + def attributes + return {} unless has_upgradeable_groups? + + { upgrade_url: upgrade_url } + end + + private + + attr_reader :user + + def has_upgradeable_groups? + owned_free_or_trial_groups.present? + end + + def owned_free_or_trial_groups + @owned_free_or_trial_groups ||= GitlabSubscriptions::Trials.owned_free_or_trial_groups(user) + end + + def upgrade_url + if single_group? + ::Gitlab::Routing.url_helpers.group_billings_path(owned_free_or_trial_groups.first) + else + ::Gitlab::Routing.url_helpers.profile_billings_path + end + end + + def single_group? + owned_free_or_trial_groups.count == 1 + end + end + end +end diff --git a/ee/spec/frontend/super_sidebar/components/user_menu_upgrade_subscription_spec.js b/ee/spec/frontend/super_sidebar/components/user_menu_upgrade_subscription_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..7db0c920da301e075ff992ea65885d407dc853b4 --- /dev/null +++ b/ee/spec/frontend/super_sidebar/components/user_menu_upgrade_subscription_spec.js @@ -0,0 +1,92 @@ +import { GlDisclosureDropdownGroup, GlIcon } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import UserMenuUpgradeSubscription from 'ee/super_sidebar/components/user_menu_upgrade_subscription.vue'; + +describe('UserMenuUpgradeSubscription component', () => { + let wrapper; + + const createWrapper = (data = {}) => { + wrapper = mountExtended(UserMenuUpgradeSubscription, { + propsData: { + data, + }, + }); + }; + + const findUpgradeSubscriptionGroup = () => wrapper.findComponent(GlDisclosureDropdownGroup); + const findUpgradeSubscriptionItem = () => wrapper.findByTestId('upgrade-subscription-item'); + + describe('when upgrade subscription is not available', () => { + beforeEach(() => { + createWrapper({ + upgrade_url: null, + }); + }); + + it('does not render the upgrade subscription group', () => { + expect(findUpgradeSubscriptionGroup().exists()).toBe(false); + }); + + it('does not render the upgrade subscription menu item', () => { + expect(findUpgradeSubscriptionItem().exists()).toBe(false); + }); + }); + + describe('when upgrade subscription is available', () => { + beforeEach(() => { + createWrapper({ + upgrade_url: '/groups/test-group/-/billings', + }); + }); + + it('renders the upgrade subscription group', () => { + expect(findUpgradeSubscriptionGroup().exists()).toBe(true); + }); + + it('renders the upgrade subscription menu item', () => { + expect(findUpgradeSubscriptionItem().exists()).toBe(true); + }); + + it('should render a link to upgrade subscription with correct URL', () => { + expect(findUpgradeSubscriptionItem().text()).toBe('Upgrade subscription'); + expect(findUpgradeSubscriptionItem().find('a').attributes('href')).toBe( + '/groups/test-group/-/billings', + ); + }); + + it('has Snowplow tracking attributes', () => { + expect(findUpgradeSubscriptionItem().find('a').attributes()).toMatchObject({ + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', + 'data-track-label': 'upgrade_subscription', + }); + }); + + it('renders with license icon', () => { + const icon = findUpgradeSubscriptionItem().findComponent(GlIcon); + + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe('license'); + }); + + it('renders with hotspot styling', () => { + const hotspotElement = findUpgradeSubscriptionItem().find('.hotspot-pulse'); + + expect(hotspotElement.exists()).toBe(true); + }); + }); + + describe('when upgrade subscription data is not provided', () => { + beforeEach(() => { + createWrapper({}); + }); + + it('does not render the upgrade subscription group', () => { + expect(findUpgradeSubscriptionGroup().exists()).toBe(false); + }); + + it('does not render the upgrade subscription menu item', () => { + expect(findUpgradeSubscriptionItem().exists()).toBe(false); + }); + }); +}); diff --git a/ee/spec/helpers/ee/sidebars_helper_spec.rb b/ee/spec/helpers/ee/sidebars_helper_spec.rb index 5166db7dc0679d1ae12f962cca17f2c315e0e946..c2ceeb87018fc34d883b9d9faa0a974968dabfb0 100644 --- a/ee/spec/helpers/ee/sidebars_helper_spec.rb +++ b/ee/spec/helpers/ee/sidebars_helper_spec.rb @@ -169,13 +169,12 @@ helper.super_sidebar_context(user, group: nil, project: nil, panel: panel, panel_type: nil) end - it 'returns sidebar values from user', :use_clean_rails_memory_store_caching do - trial = { - has_start_trial: false, - url: new_trial_path(glm_source: 'gitlab.com', glm_content: 'top-right-dropdown') - } + it 'returns upgrade url' do + expect_next_instance_of(GitlabSubscriptions::UpgradePresenter) do |presenter| + expect(presenter).to receive(:attributes).and_return({ upgrade_url: '/path' }) + end - expect(super_sidebar_context).to include(trial: trial) + expect(super_sidebar_context).to include(:upgrade_url) end describe 'for Duo agent platform widget' do diff --git a/ee/spec/helpers/users_helper_spec.rb b/ee/spec/helpers/users_helper_spec.rb index 4e22bbf3ef77095df0dbe8b21bf6afa468f69aec..ae24720a43bb856189332a151b5d4b58a5bcdb99 100644 --- a/ee/spec/helpers/users_helper_spec.rb +++ b/ee/spec/helpers/users_helper_spec.rb @@ -5,59 +5,6 @@ RSpec.describe UsersHelper, feature_category: :user_profile do let_it_be(:user) { build_stubbed(:user) } - describe '#trials_allowed?' do - context 'without cache concerns' do - using RSpec::Parameterized::TableSyntax - - where( - belongs_to_paid_namespace?: [true, false], - user?: [true, false], - subscriptions_trials_enabled: [true, false], - group_without_trial?: [true, false] - ) - - with_them do - let(:local_user) { user? ? user : nil } - - before do - stub_saas_features(subscriptions_trials: subscriptions_trials_enabled) - allow(user).to receive(:owns_group_without_trial?) { group_without_trial? } - allow(user).to receive(:belongs_to_paid_namespace?) { belongs_to_paid_namespace? } - end - - let(:expected_result) do - !belongs_to_paid_namespace? && user? && subscriptions_trials_enabled && group_without_trial? - end - - subject { helper.trials_allowed?(local_user) } - - it { is_expected.to eq(expected_result) } - end - end - - context 'with cache concerns', :use_clean_rails_redis_caching do - before do - stub_saas_features(subscriptions_trials: true) - allow(user).to receive(:owns_group_without_trial?).and_return(true) - allow(user).to receive(:belongs_to_paid_namespace?).and_return(false) - end - - it 'uses cache for result on next running of the method same user' do - expect(helper.trials_allowed?(user)).to eq(true) - - allow(user).to receive(:belongs_to_paid_namespace?).and_return(true) - - expect(helper.trials_allowed?(user)).to eq(true) - end - - it 'does not find a different user in cache result on next running of the method' do - expect(helper.trials_allowed?(user)).to eq(true) - - expect(helper.trials_allowed?(build(:user))).to eq(false) - end - end - end - describe '#user_badges_in_admin_section' do shared_examples 'shows admin role badge when user is assigned admin role' do let_it_be(:member_role) { build_stubbed(:member_role, name: 'Admin role') } diff --git a/ee/spec/models/gitlab_subscriptions/trials_spec.rb b/ee/spec/models/gitlab_subscriptions/trials_spec.rb index 791474ccf0e3d0a0d252da85422618f7026bd098..f7b63025cd9581068fe3dae15c710df2f42308c5 100644 --- a/ee/spec/models/gitlab_subscriptions/trials_spec.rb +++ b/ee/spec/models/gitlab_subscriptions/trials_spec.rb @@ -166,6 +166,37 @@ end end + describe '.owned_free_or_trial_groups', :saas, :use_clean_rails_memory_store_caching do + before_all do + create(:group_with_plan, plan: :ultimate_plan, owners: user) + create(:group_with_plan, plan: :free_plan, developers: user) + create(:group_with_plan, plan: :free_plan) + end + + let_it_be(:user) { create(:user) } + let_it_be(:free_group) { create(:group_with_plan, plan: :free_plan, owners: user) } + let_it_be(:trial_group) do + create(:group_with_plan, + plan: :ultimate_trial_plan, + owners: user, + trial: true, + trial_starts_on: 1.day.ago, + trial_ends_on: 30.days.from_now) + end + + subject { described_class.owned_free_or_trial_groups(user) } + + it { is_expected.to contain_exactly(free_group, trial_group) } + + it 'uses cache for the result' do + expect(Rails.cache).to receive(:fetch) + .with(['users', user.id, 'owned_free_or_trial_groups'], expires_in: 10.minutes) + .and_call_original + + is_expected.to contain_exactly(free_group, trial_group) + end + end + describe '.namespace_with_mid_trial_premium?', :saas do let_it_be(:free_namespace) { create(:group) } let_it_be(:premium_namespace) { create(:group_with_plan, plan: :premium_plan) } diff --git a/ee/spec/presenters/gitlab_subscriptions/upgrade_presenter_spec.rb b/ee/spec/presenters/gitlab_subscriptions/upgrade_presenter_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a7840f8add9b64161b422ae18f522ef57044c52e --- /dev/null +++ b/ee/spec/presenters/gitlab_subscriptions/upgrade_presenter_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSubscriptions::UpgradePresenter, :saas, feature_category: :subscription_management do + let(:user) { build_stubbed(:user) } + let(:namespace) { nil } + + describe '#attributes' do + subject(:attributes) { described_class.new(user, namespace: namespace).attributes } + + context 'when gitlab_com_subscriptions feature is not available' do + before do + stub_saas_features(gitlab_com_subscriptions: false) + end + + it 'returns empty hash' do + expect(attributes).to eq({}) + end + end + + context 'when gitlab_com_subscriptions feature is available' do + before do + stub_saas_features(gitlab_com_subscriptions: true) + end + + context 'without namespace provided' do + context 'when user has no owned free or trial groups' do + before do + allow(GitlabSubscriptions::Trials).to( + receive_messages( + owned_free_or_trial_groups: [] + ) + ) + end + + it 'returns empty hash' do + expect(attributes).to eq({}) + end + end + + context 'when user has exactly one free or trial group' do + let(:group) { build_stubbed(:group) } + + before do + allow(GitlabSubscriptions::Trials).to( + receive_messages( + owned_free_or_trial_groups: [group] + ) + ) + end + + it 'returns upgrade_url pointing to the group billings path' do + expected_path = ::Gitlab::Routing.url_helpers.group_billings_path(group) + expect(attributes).to eq({ upgrade_url: expected_path }) + end + end + + context 'when user has multiple free or trial groups' do + let(:groups) { [build_stubbed(:group), build_stubbed(:group)] } + + before do + allow(GitlabSubscriptions::Trials).to( + receive_messages( + owned_free_or_trial_groups: groups + ) + ) + end + + it 'returns upgrade_url pointing to profile billings path' do + expected_path = ::Gitlab::Routing.url_helpers.profile_billings_path + expect(attributes).to eq({ upgrade_url: expected_path }) + end + end + end + + context 'with namespace provided' do + let(:namespace) { build_stubbed(:group) } + + context 'when user can edit billing and namespace is free or trial' do + before do + allow(Ability).to receive(:allowed?).with(user, :edit_billing, namespace).and_return(true) + allow(namespace).to receive_messages(paid?: false, trial?: false) + end + + it 'returns upgrade_url for the specific namespace' do + expected_path = ::Gitlab::Routing.url_helpers.group_billings_path(namespace) + expect(attributes).to eq({ upgrade_url: expected_path }) + end + end + + context 'when user cannot edit billing' do + before do + allow(Ability).to receive(:allowed?).with(user, :edit_billing, namespace).and_return(false) + allow(namespace).to receive_messages(paid?: false, trial?: false) + end + + it 'returns empty hash' do + expect(attributes).to eq({}) + end + end + + context 'when namespace is paid and not trial' do + before do + allow(Ability).to receive(:allowed?).with(user, :edit_billing, namespace).and_return(true) + allow(namespace).to receive_messages(paid?: true, trial?: false) + end + + it 'returns empty hash' do + expect(attributes).to eq({}) + end + end + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 019b466aa29b0e2bd0090618123bef7fbb5eef17..b12094d9c36285523c014beb4dcd03ce807a130c 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -20495,10 +20495,10 @@ msgstr "" msgid "CurrentUser|Preferences" msgstr "" -msgid "CurrentUser|Start an Ultimate trial" +msgid "CurrentUser|Switch to GitLab Next" msgstr "" -msgid "CurrentUser|Switch to GitLab Next" +msgid "CurrentUser|Upgrade subscription" msgstr "" msgid "Currently unable to fetch data for this pipeline." diff --git a/spec/frontend/super_sidebar/components/user_menu_spec.js b/spec/frontend/super_sidebar/components/user_menu_spec.js index db7e95dc945fb1cd0651ea9d9feb3631e1a918ff..fa7b61a8f8fae4d4b9d7f5560b242802be05d813 100644 --- a/spec/frontend/super_sidebar/components/user_menu_spec.js +++ b/spec/frontend/super_sidebar/components/user_menu_spec.js @@ -42,6 +42,7 @@ describe('UserMenu component', () => { stubs: { GlEmoji, GlAvatar: true, + GlIcon: true, SetStatusModal: stubComponent(SetStatusModal), ...stubs, }, @@ -283,46 +284,6 @@ describe('UserMenu component', () => { }); }); - describe('Start Ultimate trial item', () => { - let item; - - const setItem = ({ has_start_trial } = {}) => { - createWrapper({ trial: { has_start_trial, url: '' } }); - item = wrapper.findByTestId('start-trial-item'); - }; - - describe('When Ultimate trial is not suggested for the user', () => { - it('does not render the start trial menu item', () => { - setItem(); - expect(item.exists()).toBe(false); - }); - }); - - describe('When Ultimate trial can be suggested for the user', () => { - it('does render the start trial menu item', () => { - setItem({ has_start_trial: true }); - expect(item.exists()).toBe(true); - }); - }); - - it('has Snowplow tracking attributes', () => { - setItem({ has_start_trial: true }); - expect(item.find('a').attributes()).toMatchObject({ - 'data-track-property': 'nav_user_menu', - 'data-track-action': 'click_link', - 'data-track-label': 'start_trial', - }); - }); - - describe('When trial info is not provided', () => { - it('does not render the start trial menu item', () => { - createWrapper(); - - expect(wrapper.findByTestId('start-trial-item').exists()).toBe(false); - }); - }); - }); - describe('Buy compute minutes item', () => { /** @type {import('@vue/test-utils').Wrapper} */ let item;