diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue index 2cd37875f577c00f715136e348d89c036dd6f790..94b7c1fb08a311d943e75799964d9b998c6e6092 100644 --- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue @@ -27,11 +27,16 @@ export default { HelpCenter, SidebarMenu, SidebarPortalTarget, + TrialStatusWidget: () => + import('ee_component/contextual_sidebar/components/trial_status_widget.vue'), + TrialStatusPopover: () => + import('ee_component/contextual_sidebar/components/trial_status_popover.vue'), }, mixins: [glFeatureFlagsMixin()], i18n: { skipToMainContent: __('Skip to main content'), }, + inject: ['showTrialStatusWidget'], props: { sidebarData: { type: Object, @@ -122,6 +127,12 @@ export default { {{ $options.i18n.skipToMainContent }} </gl-button> <user-bar :has-collapse-button="!isPeek" :sidebar-data="sidebarData" /> + <div v-if="showTrialStatusWidget" class="gl-px-2 gl-py-2"> + <trial-status-widget + class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-px-3 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! nav-item-link gl-py-3" + /> + <trial-status-popover /> + </div> <div class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden"> <context-switcher-toggle :context="sidebarData.current_context_header" diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js index fdd29a1719c24664a7c6950a1f599e6680ebbd50..41beb88151cdd518357b2a4ab49aa24f6026a531 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js @@ -18,6 +18,48 @@ const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), }); +const getTrialStatusWidgetData = (sidebarData) => { + if (sidebarData.trial_status_widget_data_attrs && sidebarData.trial_status_popover_data_attrs) { + const { + containerId, + trialDaysUsed, + trialDuration, + navIconImagePath, + percentageComplete, + planName, + plansHref, + } = convertObjectPropsToCamelCase(sidebarData.trial_status_widget_data_attrs); + + const { + daysRemaining, + targetId, + trialEndDate, + namespaceId, + userName, + firstName, + lastName, + companyName, + glmContent, + } = convertObjectPropsToCamelCase(sidebarData.trial_status_popover_data_attrs); + + return { + showTrialStatusWidget: true, + containerId, + trialDaysUsed: Number(trialDaysUsed), + trialDuration: Number(trialDuration), + navIconImagePath, + percentageComplete: Number(percentageComplete), + planName, + plansHref, + daysRemaining, + targetId, + trialEndDate: new Date(trialEndDate), + user: { namespaceId, userName, firstName, lastName, companyName, glmContent }, + }; + } + return { showTrialStatusWidget: false }; +}; + export const initSuperSidebar = () => { const el = document.querySelector('.js-super-sidebar'); @@ -41,6 +83,7 @@ export const initSuperSidebar = () => { rootPath, toggleNewNavEndpoint, isImpersonating, + ...getTrialStatusWidgetData(sidebarData), }, store: createStore({ searchPath, diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss index 14eec3351690b3b0118924f190704b4a92aeb939..f3cbdb1e2eb7f60e32bc968caf136139efa97f5f 100644 --- a/app/assets/stylesheets/framework/super_sidebar.scss +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -166,6 +166,11 @@ opacity: 1; } } + + #trial-status-sidebar-widget:hover { + text-decoration: none; + @include gl-text-contrast-light; + } } .super-sidebar-skip-to { diff --git a/ee/app/assets/javascripts/contextual_sidebar/components/trial_status_widget.vue b/ee/app/assets/javascripts/contextual_sidebar/components/trial_status_widget.vue index 16451a4ef39dff4ceec3d4278ba17650d6ec472e..ea2295a78a5d90ee1a6352e51e9ba6ef4674635d 100644 --- a/ee/app/assets/javascripts/contextual_sidebar/components/trial_status_widget.vue +++ b/ee/app/assets/javascripts/contextual_sidebar/components/trial_status_widget.vue @@ -51,10 +51,10 @@ export default { @click="onWidgetClick" > <div class="gl-display-flex gl-w-full"> - <span class="nav-icon-container svg-container"> + <span class="nav-icon-container svg-container gl-mr-3"> <img :src="navIconImagePath" width="16" class="svg" /> </span> - <span class="nav-item-name"> + <span class="nav-item-name gl-flex-grow-1"> {{ widgetTitle }} </span> <span class="collapse-text gl-font-sm gl-mr-auto"> diff --git a/ee/app/helpers/ee/sidebars_helper.rb b/ee/app/helpers/ee/sidebars_helper.rb index 2f4807d7507474235bd41527a7b62561c56c27ad..6a55e9ea765c2200a0c065578a756f7c14e5bd75 100644 --- a/ee/app/helpers/ee/sidebars_helper.rb +++ b/ee/app/helpers/ee/sidebars_helper.rb @@ -4,6 +4,8 @@ module EE module SidebarsHelper extend ::Gitlab::Utils::Override + include TrialStatusWidgetHelper + override :project_sidebar_context_data def project_sidebar_context_data(project, user, current_ref, **args) super.merge({ @@ -30,14 +32,16 @@ def your_work_context_data(user) override :super_sidebar_context def super_sidebar_context(user, group:, project:, panel:, panel_type:) - show_buy_pipeline_minutes = show_buy_pipeline_minutes?(project, group) + context = super + root_namespace = (project || group)&.root_ancestor - return super.merge({ show_tanuki_bot: show_tanuki_bot_chat? }) unless show_buy_pipeline_minutes + context.merge!(trial_data(root_namespace), show_tanuki_bot: show_tanuki_bot_chat?) - root_namespace = root_ancestor_namespace(project, group) + show_buy_pipeline_minutes = show_buy_pipeline_minutes?(project, group) - super.merge({ - show_tanuki_bot: show_tanuki_bot_chat?, + return context unless show_buy_pipeline_minutes && root_namespace.present? + + context.merge({ pipeline_minutes: { show_buy_pipeline_minutes: show_buy_pipeline_minutes, show_notification_dot: show_pipeline_minutes_notification_dot?(project, group), @@ -60,5 +64,37 @@ def super_sidebar_context(user, group:, project:, panel:, panel_type:) } }) end + + private + + def trial_data(root_namespace) + if root_namespace.present? && + ::Gitlab::CurrentSettings.should_check_namespace_plan? && + root_namespace.trial_active? && + can?(current_user, :admin_namespace, root_namespace) + trial_status = trial_status(root_namespace) + + return { + trial_status_widget_data_attrs: trial_status_widget_data_attrs(root_namespace, trial_status), + trial_status_popover_data_attrs: trial_status_popover_data_attrs(root_namespace, trial_status, + ultimate_plan_id) + } + end + + {} + end + + def trial_status(group) + GitlabSubscriptions::TrialStatus.new(group.trial_starts_on, group.trial_ends_on) + end + + def ultimate_plan_id + # supplying plan here rejects any free plans so we won't get that data returned + plans = GitlabSubscriptions::FetchSubscriptionPlansService.new(plan: :free).execute + + return unless plans + + plans.find { |data| data['code'] == 'ultimate' }&.fetch('id', nil) + end end end diff --git a/ee/lib/ee/sidebars/groups/panel.rb b/ee/lib/ee/sidebars/groups/panel.rb index 85fc206e9e38c3eec90585c0b5ae7adca8a25583..70b2a8931d80377ada4ba329720b1bec383244b2 100644 --- a/ee/lib/ee/sidebars/groups/panel.rb +++ b/ee/lib/ee/sidebars/groups/panel.rb @@ -14,7 +14,11 @@ def configure_menus context.is_super_sidebar ? ::Sidebars::Groups::Menus::SettingsMenu : ::Sidebars::Groups::Menus::GroupInformationMenu, ::Sidebars::Groups::Menus::EpicsMenu.new(context) ) - insert_menu_before(::Sidebars::Groups::Menus::GroupInformationMenu, ::Sidebars::Groups::Menus::TrialWidgetMenu.new(context)) + + unless context.is_super_sidebar + insert_menu_before(::Sidebars::Groups::Menus::GroupInformationMenu, ::Sidebars::Groups::Menus::TrialWidgetMenu.new(context)) + end + insert_menu_after( context.is_super_sidebar ? ::Sidebars::Groups::Menus::CiCdMenu : ::Sidebars::Groups::Menus::MergeRequestsMenu, ::Sidebars::Groups::Menus::SecurityComplianceMenu.new(context) diff --git a/ee/lib/ee/sidebars/projects/panel.rb b/ee/lib/ee/sidebars/projects/panel.rb index c0377e2543ae511d4a98bc0b774cecf07f77d79c..ebec0f4e69d3a69bdf94404fc5e6f9e9cbac7499 100644 --- a/ee/lib/ee/sidebars/projects/panel.rb +++ b/ee/lib/ee/sidebars/projects/panel.rb @@ -10,10 +10,13 @@ module Panel def configure_menus super - insert_menu_before( - ::Sidebars::Projects::Menus::ProjectInformationMenu, - ::Sidebars::Projects::Menus::TrialWidgetMenu.new(context) - ) + unless context.is_super_sidebar + insert_menu_before( + ::Sidebars::Projects::Menus::ProjectInformationMenu, + ::Sidebars::Projects::Menus::TrialWidgetMenu.new(context) + ) + end + insert_menu_after( ::Sidebars::Projects::Menus::ProjectInformationMenu, ::Sidebars::Projects::Menus::LearnGitlabMenu.new(context) diff --git a/ee/spec/frontend/contextual_sidebar/__snapshots__/trial_status_widget_spec.js.snap b/ee/spec/frontend/contextual_sidebar/__snapshots__/trial_status_widget_spec.js.snap index 27c35901ce4c0a7f4ab8c1c45be1491d857a0ef0..7a258e7646f9c12bc40e04e049e0baa54822322a 100644 --- a/ee/spec/frontend/contextual_sidebar/__snapshots__/trial_status_widget_spec.js.snap +++ b/ee/spec/frontend/contextual_sidebar/__snapshots__/trial_status_widget_spec.js.snap @@ -13,7 +13,7 @@ exports[`TrialStatusWidget component without the optional containerId prop match class="gl-display-flex gl-w-full" > <span - class="nav-icon-container svg-container" + class="nav-icon-container svg-container gl-mr-3" > <img class="svg" @@ -23,7 +23,7 @@ exports[`TrialStatusWidget component without the optional containerId prop match </span> <span - class="nav-item-name" + class="nav-item-name gl-flex-grow-1" > Ultimate Trial diff --git a/ee/spec/helpers/sidebars_helper_spec.rb b/ee/spec/helpers/sidebars_helper_spec.rb index 0e46be7726086b4bbddb76a729e952e1b8350b3b..441c8f2469f6f1314c0892e87ee32c73fd3dc411 100644 --- a/ee/spec/helpers/sidebars_helper_spec.rb +++ b/ee/spec/helpers/sidebars_helper_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' RSpec.describe ::SidebarsHelper, feature_category: :navigation do + using RSpec::Parameterized::TableSyntax include Devise::Test::ControllerHelpers describe '#super_sidebar_context' do @@ -50,12 +51,79 @@ end end + shared_examples 'trial status widget data' do + describe 'trial status on .com', :saas do + let_it_be(:root_group) { namespace.root_ancestor } + let_it_be(:gitlab_subscription) { build(:gitlab_subscription, :active_trial, :free, namespace: root_group) } + + before do + allow_next_instance_of(GitlabSubscriptions::FetchSubscriptionPlansService) do |instance| + allow(instance).to receive(:execute).and_return([{ 'code' => 'ultimate', 'id' => 'ultimate-plan-id' }]) + end + end + + describe 'does not return trial status widget data' do + where(:description, :should_check_namespace_plan, :trial_active, :can_admin) do + 'when instance does not check namespace plan' | false | true | true + 'when no trial is active' | true | false | true + 'when user cannot admin namespace' | true | true | false + end + + with_them do + before do + allow(helper).to receive(:can?).with(user, :admin_namespace, root_group).and_return(can_admin) + stub_ee_application_setting(should_check_namespace_plan: should_check_namespace_plan) + allow(root_group).to receive(:trial_active?).and_return(trial_active) + end + + it { is_expected.not_to include(:trial_status_widget_data_attrs) } + it { is_expected.not_to include(:trial_status_popover_data_attrs) } + end + end + + context 'when a trial is in progress' do + before do + allow(helper).to receive(:can?).with(user, :admin_namespace, root_group).and_return(true) + stub_ee_application_setting(should_check_namespace_plan: true) + allow(root_group).to receive(:trial_active?).and_return(true) + end + + it 'returns trial status widget data' do + expect(subject[:trial_status_widget_data_attrs]).to match({ + container_id: "trial-status-sidebar-widget", + nav_icon_image_path: match_asset_path("/assets/illustrations/golden_tanuki.svg"), + percentage_complete: 50.0, + plan_name: nil, + plans_href: group_billings_path(root_group), + trial_days_used: 15, + trial_duration: 30 + }) + expect(subject[:trial_status_popover_data_attrs]).to eq({ + company_name: "", + container_id: "trial-status-sidebar-widget", + days_remaining: 15, + first_name: user.first_name, + glm_content: "trial-status-show-group", + last_name: user.last_name, + namespace_id: nil, + plan_name: nil, + plans_href: group_billings_path(root_group), + target_id: "trial-status-sidebar-widget", + trial_end_date: root_group.trial_ends_on, + user_name: user.username + }) + end + end + end + end + context 'when in project scope' do before do allow(helper).to receive(:show_buy_pipeline_minutes?).and_return(true) end let_it_be(:project) { build(:project) } + let_it_be(:namespace) { project } let_it_be(:group) { nil } let(:subject) do @@ -63,6 +131,7 @@ end include_examples 'pipeline minutes attributes' + include_examples 'trial status widget data' it 'returns correct usage quotes path', :use_clean_rails_memory_store_caching do expect(subject[:pipeline_minutes]).to include({ @@ -77,6 +146,7 @@ end let_it_be(:group) { build(:group) } + let_it_be(:namespace) { group } let_it_be(:project) { nil } let(:subject) do @@ -84,6 +154,7 @@ end include_examples 'pipeline minutes attributes' + include_examples 'trial status widget data' it 'returns correct usage quotes path', :use_clean_rails_memory_store_caching do expect(subject[:pipeline_minutes]).to include({ diff --git a/ee/spec/lib/sidebars/projects/menus/trial_widget_menu_spec.rb b/ee/spec/lib/sidebars/projects/menus/trial_widget_menu_spec.rb index 60223ae2e65cfc6380e118392adab131dc7f6921..8b9369b0644ab8d747278a1177db0c1d93a7fd25 100644 --- a/ee/spec/lib/sidebars/projects/menus/trial_widget_menu_spec.rb +++ b/ee/spec/lib/sidebars/projects/menus/trial_widget_menu_spec.rb @@ -3,6 +3,10 @@ require 'spec_helper' RSpec.describe Sidebars::Projects::Menus::TrialWidgetMenu, :saas, feature_category: :experimentation_conversion do + before do + stub_feature_flags(super_sidebar_nav: true) + end + it_behaves_like 'trial widget menu items' do let(:context) do container = instance_double(Project, namespace: group) diff --git a/spec/frontend/super_sidebar/components/super_sidebar_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_spec.js index cd2f3c89ed909d57130cc6038073dfab8d65d9a8..72ed5dbba15a9da133e697395ebc83e0283f61eb 100644 --- a/spec/frontend/super_sidebar/components/super_sidebar_spec.js +++ b/spec/frontend/super_sidebar/components/super_sidebar_spec.js @@ -21,6 +21,13 @@ import { sidebarData } from '../mock_data'; jest.mock('~/super_sidebar/super_sidebar_collapsed_state_manager'); const focusInputMock = jest.fn(); +const trialStatusWidgetStubTestId = 'trial-status-widget'; +const TrialStatusWidgetStub = { template: `<div data-testid="${trialStatusWidgetStubTestId}" />` }; +const trialStatusPopoverStubTestId = 'trial-status-popover'; +const TrialStatusPopoverStub = { + template: `<div data-testid="${trialStatusPopoverStubTestId}" />`, +}; + describe('SuperSidebar component', () => { let wrapper; @@ -29,24 +36,30 @@ describe('SuperSidebar component', () => { const findUserBar = () => wrapper.findComponent(UserBar); const findHelpCenter = () => wrapper.findComponent(HelpCenter); const findSidebarPortalTarget = () => wrapper.findComponent(SidebarPortalTarget); + const findTrialStatusWidget = () => wrapper.findByTestId(trialStatusWidgetStubTestId); + const findTrialStatusPopover = () => wrapper.findByTestId(trialStatusPopoverStubTestId); - const createWrapper = ({ props = {}, provide = {}, sidebarState = {} } = {}) => { + const createWrapper = ({ provide = {}, sidebarState = {} } = {}) => { wrapper = shallowMountExtended(SuperSidebar, { data() { return { ...sidebarState, }; }, + provide: { + showTrialStatusWidget: false, + ...provide, + }, propsData: { sidebarData, - ...props, }, stubs: { ContextSwitcher: stubComponent(ContextSwitcher, { methods: { focusInput: focusInputMock }, }), + TrialStatusWidget: TrialStatusWidgetStub, + TrialStatusPopover: TrialStatusPopoverStub, }, - provide, }); }; @@ -113,6 +126,13 @@ describe('SuperSidebar component', () => { expect(Mousetrap.unbind).toHaveBeenCalledWith(['mod+\\']); }); + + it('does not render trial status widget', () => { + createWrapper(); + + expect(findTrialStatusWidget().exists()).toBe(false); + expect(findTrialStatusPopover().exists()).toBe(false); + }); }); describe('when peeking on hover', () => { @@ -213,4 +233,15 @@ describe('SuperSidebar component', () => { expect(focusInputMock).toHaveBeenCalledTimes(1); }); }); + + describe('when a trial is active', () => { + beforeEach(() => { + createWrapper({ provide: { showTrialStatusWidget: true } }); + }); + + it('renders trial status widget', () => { + expect(findTrialStatusWidget().exists()).toBe(true); + expect(findTrialStatusPopover().exists()).toBe(true); + }); + }); });