diff --git a/app/components/enterprise_edition/banner_component.rb b/app/components/enterprise_edition/banner_component.rb index 9f778e107d2..80be876e196 100644 --- a/app/components/enterprise_edition/banner_component.rb +++ b/app/components/enterprise_edition/banner_component.rb @@ -44,7 +44,7 @@ module EnterpriseEdition VARIANT_OPTIONS = %i[inline medium large].freeze # @param feature_key [Symbol, NilClass] The key of the feature to show the banner for. - # @param variant [Symbol, NilClass] The variant of the banner comopnent. + # @param variant [Symbol, NilClass] The variant of the banner component. # @param image [String, NilClass] Path to the image to show on the banner, or nil. # Only applicable and required when variant is :medium. # @param video [String, NilClass] Path to the video to show on the banner, or nil. @@ -55,7 +55,7 @@ module EnterpriseEdition # @param show_always [boolean] Always show the banner, regardless of the dismissed or feature state. # @param dismiss_key [String] Provide a string to identify this banner when being dismissed. Defaults to feature_key # @param system_arguments [Hash] <%= link_to_system_arguments_docs %> - def initialize(feature_key, # rubocop:disable Metrics/AbcSize + def initialize(feature_key, variant: DEFAULT_VARIANT, image: nil, video: nil, @@ -68,12 +68,15 @@ module EnterpriseEdition @image = image @video = video @dismissable = dismissable - @dismiss_key = dismiss_key + @dismiss_key = dismiss_key.to_s + @show_always = show_always self.feature_key = feature_key self.i18n_scope = i18n_scope + trial_overrides! if trial_feature? + if @variant == :medium && @image.nil? raise ArgumentError, "The 'image' parameter is required when the variant is :medium." end @@ -82,17 +85,7 @@ module EnterpriseEdition raise ArgumentError, "The 'video' parameter is required when the variant is :large." end - @system_arguments = system_arguments - @system_arguments[:tag] = :div - @system_arguments[:mb] ||= 2 - @system_arguments[:id] = "op-enterprise-banner-#{feature_key.to_s.tr('_', '-')}" - @system_arguments[:test_selector] = "op-enterprise-banner" - @system_arguments[:classes] = class_names( - @system_arguments[:classes], - "op-enterprise-banner", - @variant == :medium ? "op-enterprise-banner_medium" : nil, - @variant == :large ? "op-enterprise-banner_large" : nil - ) + set_system_arguments(system_arguments, feature_key) super end @@ -115,15 +108,39 @@ module EnterpriseEdition end def wrapper_key - "enterprise_banner_#{feature_key}" + "enterprise_banner_#{@dismiss_key}" end private + def set_system_arguments(system_arguments, feature_key) + @system_arguments = system_arguments + @system_arguments[:tag] = :div + @system_arguments[:mb] ||= 2 + @system_arguments[:id] = "op-enterprise-banner-#{feature_key.to_s.tr('_', '-')}" + @system_arguments[:test_selector] = "op-enterprise-banner" + @system_arguments[:classes] = class_names( + @system_arguments[:classes], + "op-enterprise-banner", + "op-enterprise-banner_medium" => @variant == :medium, + "op-enterprise-banner_large" => @variant == :large, + "op-enterprise-banner_trial" => trial_feature? + ) + end + + def trial_overrides! + @dismissable = true + @dismiss_key += "_trial" unless @dismiss_key.end_with?("_trial") + @variant = :inline + end + def render? return true if @show_always + return false if dismissed? + return true if feature_available? && trial_feature? + return false if EnterpriseToken.hide_banners? - !(EnterpriseToken.hide_banners? || feature_available? || dismissed?) + !feature_available? end def feature_available? @@ -135,5 +152,9 @@ module EnterpriseEdition User.current.pref.dismissed_banner?(@dismiss_key) end + + def trial_feature? + EnterpriseToken.trialling?(feature_key) + end end end diff --git a/app/components/enterprise_edition/upsell_buttons_component.rb b/app/components/enterprise_edition/upsell_buttons_component.rb index 2eaf28d164b..82cce0f45cf 100644 --- a/app/components/enterprise_edition/upsell_buttons_component.rb +++ b/app/components/enterprise_edition/upsell_buttons_component.rb @@ -58,6 +58,7 @@ module EnterpriseEdition def buttons [ + buy_now_button, free_trial_button, upgrade_now_button, more_info_button @@ -74,6 +75,20 @@ module EnterpriseEdition ) end + def buy_now_button + return unless EnterpriseToken.active? + return unless User.current.admin? + + render(Primer::Beta::Button.new( + classes: "upsell-colored-background", + tag: :a, + href: OpenProject::Enterprise.upgrade_path, + align_self: :center + )) do + I18n.t("ee.upsell.buy_now_button") + end + end + # Allow providing a custom upgrade now button def upgrade_now_button nil diff --git a/app/components/settings/project_life_cycle_step_definitions/index_component.html.erb b/app/components/settings/project_life_cycle_step_definitions/index_component.html.erb index e6a8145822c..93bfb3498fb 100644 --- a/app/components/settings/project_life_cycle_step_definitions/index_component.html.erb +++ b/app/components/settings/project_life_cycle_step_definitions/index_component.html.erb @@ -31,7 +31,14 @@ See COPYRIGHT and LICENSE files for more details. component_wrapper do flex_layout(data: wrapper_data_attributes) do |flex| flex.with_row do - if allowed_to_customize_life_cycle? + render EnterpriseEdition::BannerComponent.new(:customize_life_cycle, + variant: :medium, + image: "enterprise/project-lifecycle.png", + mb: 3) + end + + if allowed_to_customize_life_cycle? + flex.with_row do render(Primer::OpenProject::SubHeader.new) do |subheader| subheader.with_filter_input( name: "border-box-filter", @@ -57,11 +64,6 @@ See COPYRIGHT and LICENSE files for more details. I18n.t("settings.project_phase_definitions.label_add") end end - else - render EnterpriseEdition::BannerComponent.new(:customize_life_cycle, - variant: :medium, - image: "enterprise/project-lifecycle.png", - mb: 3) end end diff --git a/app/components/shares/modal_body_component.html.erb b/app/components/shares/modal_body_component.html.erb index 3bc42afca95..7612091818c 100644 --- a/app/components/shares/modal_body_component.html.erb +++ b/app/components/shares/modal_body_component.html.erb @@ -7,7 +7,8 @@ end end - render(strategy.manage_shares_component(modal_content:, errors:)) + render(strategy.upsell_banner(modal_content:)) + render(strategy.manage_shares_component(modal_content:, errors:)) if strategy.allow_feature? end end %> diff --git a/app/components/shares/work_packages/modal_upsell_component.html.erb b/app/components/shares/work_packages/modal_upsell_component.html.erb index b283aba4e06..f10285902d1 100644 --- a/app/components/shares/work_packages/modal_upsell_component.html.erb +++ b/app/components/shares/work_packages/modal_upsell_component.html.erb @@ -1,5 +1,7 @@ <%= component_wrapper(tag: "turbo-frame") do - render(EnterpriseEdition::BannerComponent.new(:work_package_sharing)) + modal_content.with_row do + render(EnterpriseEdition::BannerComponent.new(:work_package_sharing)) + end end %> diff --git a/app/components/shares/work_packages/modal_upsell_component.rb b/app/components/shares/work_packages/modal_upsell_component.rb index 3b9efbb5a81..a26e98f422c 100644 --- a/app/components/shares/work_packages/modal_upsell_component.rb +++ b/app/components/shares/work_packages/modal_upsell_component.rb @@ -33,6 +33,14 @@ module Shares include OpTurbo::Streamable include OpPrimer::ComponentHelpers + def initialize(modal_content:) + super + + @modal_content = modal_content + end + + attr_reader :modal_content + def self.wrapper_key "share_modal_body" end diff --git a/app/controllers/my/enterprise_banners_controller.rb b/app/controllers/my/enterprise_banners_controller.rb index e627b18ae77..c9cca555a87 100644 --- a/app/controllers/my/enterprise_banners_controller.rb +++ b/app/controllers/my/enterprise_banners_controller.rb @@ -47,9 +47,13 @@ class My::EnterpriseBannersController < ApplicationController def dismiss pref = User.current.pref - pref.dismiss_banner(@feature_key) + pref.dismiss_banner(@dismiss_key) if pref.save - remove_via_turbo_stream(component: EnterpriseEdition::BannerComponent.new(@feature_key)) + remove_via_turbo_stream(component: EnterpriseEdition::BannerComponent.new( + @feature_key, + dismiss_key: @dismiss_key, + show_always: true + )) respond_with_turbo_streams else respond_with_flash_error(message: call.message) @@ -59,7 +63,11 @@ class My::EnterpriseBannersController < ApplicationController private def get_feature_key - @feature_key = params[:feature_key].to_sym + raw_key = params[:feature_key] + + @dismiss_key = raw_key + @feature_key = raw_key.gsub(/_trial$/, "").to_sym + render_400 unless OpenProject::Token.lowest_plan_for(@feature_key) end end diff --git a/app/forms/statuses/form.rb b/app/forms/statuses/form.rb index 2948f1356aa..cac0631dde9 100644 --- a/app/forms/statuses/form.rb +++ b/app/forms/statuses/form.rb @@ -86,10 +86,8 @@ module Statuses } ) - if readonly_work_packages_restricted? - statuses_form.html_content do - render(EnterpriseEdition::BannerComponent.new(:readonly_work_packages)) - end + statuses_form.html_content do + render(EnterpriseEdition::BannerComponent.new(:readonly_work_packages)) end statuses_form.check_box( diff --git a/app/models/enterprise_token.rb b/app/models/enterprise_token.rb index 2dcad4af441..e5559fb4a5f 100644 --- a/app/models/enterprise_token.rb +++ b/app/models/enterprise_token.rb @@ -45,6 +45,18 @@ class EnterpriseToken < ApplicationRecord current && !current.expired? end + def available_features + EnterpriseToken.current&.available_features || [] + end + + def trialling_features + available_features.select { |feature| trialling?(feature) } + end + + def trialling?(feature) + allows_to?(feature) && EnterpriseToken.current.trial? + end + def hide_banners? OpenProject::Configuration.ee_hide_banners? end @@ -88,6 +100,7 @@ class EnterpriseToken < ApplicationRecord :plan, :features, :version, + :trial?, to: :token_object def token_object diff --git a/app/models/sharing_strategies/project_query_strategy.rb b/app/models/sharing_strategies/project_query_strategy.rb index 9ee3c9922e1..cd30bb88804 100644 --- a/app/models/sharing_strategies/project_query_strategy.rb +++ b/app/models/sharing_strategies/project_query_strategy.rb @@ -105,18 +105,19 @@ module SharingStrategies end end - def manage_shares_component(modal_content:, errors:) - if EnterpriseToken.allows_to?(:project_list_sharing) - super - else - Shares::ProjectQueries::UpsellComponent.new(modal_content:) - end - end - def title I18n.t(:label_share_project_list) end + def upsell_banner(modal_content:) + Shares::ProjectQueries::UpsellComponent.new(modal_content:) + end + + def allow_feature? + EnterpriseToken.allows_to?(:project_list_sharing) || + EnterpriseToken.trialling?(:project_list_sharing) + end + private def virtual_owner_share diff --git a/app/models/sharing_strategies/work_package_strategy.rb b/app/models/sharing_strategies/work_package_strategy.rb index d51c3be999f..e398663d0e9 100644 --- a/app/models/sharing_strategies/work_package_strategy.rb +++ b/app/models/sharing_strategies/work_package_strategy.rb @@ -99,18 +99,19 @@ module SharingStrategies Shares::WorkPackages::DeleteContract end - def modal_body_component(errors) - if EnterpriseToken.allows_to?(:work_package_sharing) - super - else - Shares::WorkPackages::ModalUpsellComponent.new - end - end - def title I18n.t(:label_share_work_package) end + def upsell_banner(modal_content:) + Shares::WorkPackages::ModalUpsellComponent.new(modal_content:) + end + + def allow_feature? + EnterpriseToken.allows_to?(:work_package_sharing) || + EnterpriseToken.trialling?(:work_package_sharing) + end + private def project_member?(share) diff --git a/app/views/custom_fields/new.html.erb b/app/views/custom_fields/new.html.erb index 448c0fba16d..aac6a2f74dc 100644 --- a/app/views/custom_fields/new.html.erb +++ b/app/views/custom_fields/new.html.erb @@ -46,7 +46,7 @@ See COPYRIGHT and LICENSE files for more details. <% content_controller "admin--custom-fields", dynamic: true, "admin--custom-fields-format-config-value": OpenProject::CustomFieldFormatDependent.stimulus_config, - "admin--custom-fields-enterprise-edition-value": EnterpriseToken.allows_to?(:custom_field_hierarchies) %> + "admin--custom-fields-hierarchy-enabled-value": EnterpriseToken.allows_to?(:custom_field_hierarchies) %> <%= labelled_tabular_form_for @custom_field, as: :custom_field, url: custom_fields_path, diff --git a/config/locales/en.yml b/config/locales/en.yml index 808db78226d..e5c4f80ca85 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2094,12 +2094,12 @@ en: readonly_work_packages: Readonly Work Packages sso_auth_providers: Single sign-on team_planner_view: Team Planner View - two_factor_authentication: 2FA Authentication virus_scanning: Antivirus Scanning work_package_query_relation_columns: Work Package Query Relation Columns work_package_sharing: Share work packages with external users work_package_subject_generation: Work Package Subject Generation upsell: + buy_now_button: "Buy now" plans_title: "Enterprise plans" title: "Enterprise add-on" plan_title: "Enterprise %{plan} add-on" diff --git a/frontend/src/app/core/config/configuration.service.ts b/frontend/src/app/core/config/configuration.service.ts index 027a23cc87a..071ac782ae0 100644 --- a/frontend/src/app/core/config/configuration.service.ts +++ b/frontend/src/app/core/config/configuration.service.ts @@ -150,6 +150,10 @@ export class ConfigurationService { return this.systemPreference('availableFeatures'); } + public get triallingFeatures():string[] { + return this.systemPreference('triallingFeatures'); + } + private loadConfiguration() { return this .apiV3Service diff --git a/frontend/src/app/core/enterprise/banners.service.ts b/frontend/src/app/core/enterprise/banners.service.ts index 4a74e35158d..9912d212b01 100644 --- a/frontend/src/app/core/enterprise/banners.service.ts +++ b/frontend/src/app/core/enterprise/banners.service.ts @@ -43,7 +43,19 @@ export class BannersService { } public showBannerFor(feature:string):boolean { - return !(this._bannersHidden || this.configuration.availableFeatures.includes(feature)); + if (this._bannersHidden) { + return false; + } + + return !this.allowsTo(feature) || this.trialling(feature); + } + + public allowsTo(feature:string):boolean { + return this.configuration.availableFeatures.includes(feature); + } + + public trialling(feature:string):boolean { + return this.configuration.triallingFeatures.includes(feature); } public getEnterPriseEditionUrl({ referrer, hash }:{ referrer?:string, hash?:string } = {}) { @@ -59,13 +71,13 @@ export class BannersService { return url.toString(); } - public async conditional(feature:string, bannersVisible?:() => void, bannersNotVisible?:() => void) { + public async conditional(feature:string, featureNotAvailable?:() => void, featureAvailable?:() => void) { await this.configuration.initialize(); - if (this.showBannerFor(feature)) { - this.callMaybe(bannersVisible); + if (this.allowsTo(feature)) { + this.callMaybe(featureAvailable); } else { - this.callMaybe(bannersNotVisible); + this.callMaybe(featureNotAvailable); } } diff --git a/frontend/src/app/features/admin/types/type-banner.service.ts b/frontend/src/app/features/admin/types/type-banner.service.ts index 18a355a5af0..3d5937bf2d7 100644 --- a/frontend/src/app/features/admin/types/type-banner.service.ts +++ b/frontend/src/app/features/admin/types/type-banner.service.ts @@ -7,7 +7,7 @@ import { ConfigurationService } from 'core-app/core/config/configuration.service @Injectable() export class TypeBannerService extends BannersService { - showBanners = this.showBannerFor('edit_attribute_groups'); + eeAvailable = this.allowsTo('edit_attribute_groups'); constructor( @Inject(DOCUMENT) protected documentElement:Document, diff --git a/frontend/src/app/features/admin/types/type-form-configuration.html b/frontend/src/app/features/admin/types/type-form-configuration.html index ef1ca845855..2fd43ea8544 100644 --- a/frontend/src/app/features/admin/types/type-form-configuration.html +++ b/frontend/src/app/features/admin/types/type-form-configuration.html @@ -10,7 +10,7 @@