rails_onboarding 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +1351 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/rails_onboarding/admin/chart_controller.js +25 -0
- data/app/assets/javascripts/rails_onboarding/admin/filter_controller.js +49 -0
- data/app/assets/javascripts/rails_onboarding/admin/flash_controller.js +21 -0
- data/app/assets/javascripts/rails_onboarding/admin/flow_editor_controller.js +86 -0
- data/app/assets/javascripts/rails_onboarding/application.js +231 -0
- data/app/assets/javascripts/rails_onboarding/milestone_celebration_controller.js +177 -0
- data/app/assets/javascripts/rails_onboarding/milestone_dashboard_controller.js +132 -0
- data/app/assets/javascripts/rails_onboarding/milestone_detail_controller.js +34 -0
- data/app/assets/javascripts/rails_onboarding/navigation_controller.js +277 -0
- data/app/assets/javascripts/rails_onboarding/onboarding_controller.js +326 -0
- data/app/assets/javascripts/rails_onboarding/progress_controller.js +203 -0
- data/app/assets/javascripts/rails_onboarding/progressive_disclosure_controller.js +218 -0
- data/app/assets/javascripts/rails_onboarding/tooltip_controller.js +1237 -0
- data/app/assets/javascripts/rails_onboarding/tooltip_scheduler_controller.js +511 -0
- data/app/assets/javascripts/rails_onboarding/tour_controller.js +763 -0
- data/app/assets/stylesheets/rails_onboarding/accessibility.css +536 -0
- data/app/assets/stylesheets/rails_onboarding/admin.css +816 -0
- data/app/assets/stylesheets/rails_onboarding/application.css +1145 -0
- data/app/assets/stylesheets/rails_onboarding/flash_messages.css +237 -0
- data/app/assets/stylesheets/rails_onboarding/milestones.css +545 -0
- data/app/assets/stylesheets/rails_onboarding/mobile.css +856 -0
- data/app/assets/stylesheets/rails_onboarding/progressive_disclosure.css +299 -0
- data/app/assets/stylesheets/rails_onboarding/tooltips.css +468 -0
- data/app/assets/stylesheets/rails_onboarding/tour.css +495 -0
- data/app/assets/stylesheets/rails_onboarding/utilities.css +393 -0
- data/app/controllers/concerns/rails_onboarding/admin_authorization.rb +98 -0
- data/app/controllers/concerns/rails_onboarding/rate_limitable.rb +104 -0
- data/app/controllers/rails_onboarding/ab_tests_controller.rb +257 -0
- data/app/controllers/rails_onboarding/admin/ab_tests_controller.rb +121 -0
- data/app/controllers/rails_onboarding/admin/base_controller.rb +68 -0
- data/app/controllers/rails_onboarding/admin/dashboard_controller.rb +189 -0
- data/app/controllers/rails_onboarding/admin/flows_controller.rb +175 -0
- data/app/controllers/rails_onboarding/admin/users_controller.rb +366 -0
- data/app/controllers/rails_onboarding/application_controller.rb +9 -0
- data/app/controllers/rails_onboarding/milestones_controller.rb +126 -0
- data/app/controllers/rails_onboarding/onboarding_controller.rb +409 -0
- data/app/controllers/rails_onboarding/progressive_features_controller.rb +134 -0
- data/app/controllers/rails_onboarding/templates_controller.rb +162 -0
- data/app/controllers/rails_onboarding/test_sessions_controller.rb +16 -0
- data/app/controllers/rails_onboarding/tooltips_controller.rb +92 -0
- data/app/helpers/rails_onboarding/application_helper.rb +4 -0
- data/app/helpers/rails_onboarding/i18n_helper.rb +137 -0
- data/app/helpers/rails_onboarding/personalization_helper.rb +184 -0
- data/app/helpers/rails_onboarding/templates_helper.rb +253 -0
- data/app/jobs/rails_onboarding/application_job.rb +4 -0
- data/app/mailers/rails_onboarding/application_mailer.rb +6 -0
- data/app/mailers/rails_onboarding/onboarding_mailer.rb +72 -0
- data/app/models/concerns/rails_onboarding/ab_testable.rb +149 -0
- data/app/models/concerns/rails_onboarding/onboardable.rb +649 -0
- data/app/models/concerns/rails_onboarding/personalizable.rb +211 -0
- data/app/models/concerns/rails_onboarding/progressive_disclosure.rb +276 -0
- data/app/models/rails_onboarding/analytics.rb +255 -0
- data/app/models/rails_onboarding/analytics_event.rb +168 -0
- data/app/models/rails_onboarding/application_record.rb +5 -0
- data/app/models/rails_onboarding/flow.rb +41 -0
- data/app/services/rails_onboarding/milestone_service.rb +105 -0
- data/app/views/layouts/rails_onboarding/admin.html.erb +106 -0
- data/app/views/layouts/rails_onboarding/application.html.erb +65 -0
- data/app/views/rails_onboarding/ab_tests/index.html.erb +209 -0
- data/app/views/rails_onboarding/ab_tests/results.html.erb +253 -0
- data/app/views/rails_onboarding/admin/ab_tests/_ab_test_card.html.erb +24 -0
- data/app/views/rails_onboarding/admin/ab_tests/index.html.erb +57 -0
- data/app/views/rails_onboarding/admin/ab_tests/show.html.erb +41 -0
- data/app/views/rails_onboarding/admin/dashboard/index.html.erb +224 -0
- data/app/views/rails_onboarding/admin/flows/_form.html.erb +147 -0
- data/app/views/rails_onboarding/admin/flows/edit.html.erb +7 -0
- data/app/views/rails_onboarding/admin/flows/index.html.erb +125 -0
- data/app/views/rails_onboarding/admin/flows/new.html.erb +7 -0
- data/app/views/rails_onboarding/admin/flows/preview.html.erb +40 -0
- data/app/views/rails_onboarding/admin/flows/show.html.erb +114 -0
- data/app/views/rails_onboarding/admin/users/index.html.erb +180 -0
- data/app/views/rails_onboarding/admin/users/show.html.erb +187 -0
- data/app/views/rails_onboarding/milestones/index.html.erb +64 -0
- data/app/views/rails_onboarding/milestones/show.html.erb +57 -0
- data/app/views/rails_onboarding/onboarding/_custom_footer.html.erb +2 -0
- data/app/views/rails_onboarding/onboarding/_progress_indicator.html.erb +56 -0
- data/app/views/rails_onboarding/onboarding/_step_navigation.html.erb +44 -0
- data/app/views/rails_onboarding/onboarding/step.html.erb +38 -0
- data/app/views/rails_onboarding/onboarding/welcome.html.erb +37 -0
- data/app/views/rails_onboarding/onboarding_mailer/completion_email.html.erb +109 -0
- data/app/views/rails_onboarding/onboarding_mailer/completion_email.text.erb +29 -0
- data/app/views/rails_onboarding/onboarding_mailer/reminder_email.html.erb +99 -0
- data/app/views/rails_onboarding/onboarding_mailer/reminder_email.text.erb +20 -0
- data/app/views/rails_onboarding/onboarding_mailer/step_completed_email.html.erb +105 -0
- data/app/views/rails_onboarding/onboarding_mailer/step_completed_email.text.erb +19 -0
- data/app/views/rails_onboarding/onboarding_mailer/welcome_email.html.erb +74 -0
- data/app/views/rails_onboarding/onboarding_mailer/welcome_email.text.erb +17 -0
- data/app/views/rails_onboarding/shared/_flash.html.erb +25 -0
- data/app/views/rails_onboarding/shared/_milestone_badge.html.erb +22 -0
- data/app/views/rails_onboarding/shared/_milestone_celebration.html.erb +44 -0
- data/app/views/rails_onboarding/shared/_onboarding_banner.html.erb +48 -0
- data/app/views/rails_onboarding/templates/index.html.erb +255 -0
- data/config/importmap.rb +33 -0
- data/config/locales/en.yml +62 -0
- data/config/locales/es.yml +62 -0
- data/config/locales/fr.yml +62 -0
- data/config/routes.rb +79 -0
- data/lib/generators/rails_onboarding/install_generator.rb +124 -0
- data/lib/generators/rails_onboarding/templates/README +120 -0
- data/lib/generators/rails_onboarding/templates/add_analytics_to_rails_onboarding.rb +33 -0
- data/lib/generators/rails_onboarding/templates/add_milestone_tracking_to_users.rb +38 -0
- data/lib/generators/rails_onboarding/templates/add_onboarding_indexes.rb +93 -0
- data/lib/generators/rails_onboarding/templates/add_onboarding_to_users.rb +51 -0
- data/lib/generators/rails_onboarding/templates/add_robustness_fields_to_users.rb.tt +20 -0
- data/lib/generators/rails_onboarding/templates/create_rails_onboarding_flows.rb +18 -0
- data/lib/generators/rails_onboarding/templates/onboarding.css +18 -0
- data/lib/generators/rails_onboarding/templates/rails_onboarding.rb +75 -0
- data/lib/generators/rails_onboarding/update_generator.rb +73 -0
- data/lib/rails_onboarding/api_mode.rb +367 -0
- data/lib/rails_onboarding/backfill.rb +130 -0
- data/lib/rails_onboarding/background_jobs.rb +412 -0
- data/lib/rails_onboarding/caching.rb +180 -0
- data/lib/rails_onboarding/cdn_support.rb +202 -0
- data/lib/rails_onboarding/configuration/ab_testing.rb +24 -0
- data/lib/rails_onboarding/configuration/analytics.rb +29 -0
- data/lib/rails_onboarding/configuration/integrations.rb +31 -0
- data/lib/rails_onboarding/configuration/milestones.rb +92 -0
- data/lib/rails_onboarding/configuration/personalization.rb +25 -0
- data/lib/rails_onboarding/configuration/progressive_disclosure.rb +41 -0
- data/lib/rails_onboarding/configuration/rate_limiting.rb +18 -0
- data/lib/rails_onboarding/configuration/steps.rb +121 -0
- data/lib/rails_onboarding/configuration/templates.rb +85 -0
- data/lib/rails_onboarding/configuration/tooltips.rb +32 -0
- data/lib/rails_onboarding/configuration.rb +133 -0
- data/lib/rails_onboarding/configuration_errors.rb +19 -0
- data/lib/rails_onboarding/configuration_validator.rb +477 -0
- data/lib/rails_onboarding/controller_helpers.rb +153 -0
- data/lib/rails_onboarding/current.rb +13 -0
- data/lib/rails_onboarding/deprecation.rb +64 -0
- data/lib/rails_onboarding/devise_integration.rb +122 -0
- data/lib/rails_onboarding/engine.rb +245 -0
- data/lib/rails_onboarding/error_recovery.rb +173 -0
- data/lib/rails_onboarding/lazy_loading.rb +155 -0
- data/lib/rails_onboarding/multi_tenant.rb +306 -0
- data/lib/rails_onboarding/requirements_validator.rb +315 -0
- data/lib/rails_onboarding/responsive_helper.rb +218 -0
- data/lib/rails_onboarding/session_manager.rb +187 -0
- data/lib/rails_onboarding/skip_logic.rb +159 -0
- data/lib/rails_onboarding/turbo_compatibility.rb +229 -0
- data/lib/rails_onboarding/version.rb +3 -0
- data/lib/rails_onboarding.rb +44 -0
- data/lib/tasks/rails_onboarding_analytics.rake +136 -0
- data/lib/tasks/rails_onboarding_tasks.rake +162 -0
- metadata +303 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module RailsOnboarding
|
|
2
|
+
class Configuration
|
|
3
|
+
# Feature tooltip configuration.
|
|
4
|
+
module Tooltips
|
|
5
|
+
def enable_tooltips
|
|
6
|
+
tenant_override(:enable_tooltips, @enable_tooltips)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
attr_writer :enable_tooltips
|
|
10
|
+
|
|
11
|
+
def feature_tooltips
|
|
12
|
+
tenant_override(:feature_tooltips, @feature_tooltips)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_writer :feature_tooltips
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def initialize_tooltips
|
|
20
|
+
@enable_tooltips = true
|
|
21
|
+
|
|
22
|
+
@feature_tooltips = {
|
|
23
|
+
"getting_started" => {
|
|
24
|
+
text: "Click here to get started!",
|
|
25
|
+
delay: 1000,
|
|
26
|
+
position: "bottom"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
require_relative "configuration_validator"
|
|
2
|
+
require_relative "configuration/steps"
|
|
3
|
+
require_relative "configuration/milestones"
|
|
4
|
+
require_relative "configuration/tooltips"
|
|
5
|
+
require_relative "configuration/analytics"
|
|
6
|
+
require_relative "configuration/ab_testing"
|
|
7
|
+
require_relative "configuration/personalization"
|
|
8
|
+
require_relative "configuration/progressive_disclosure"
|
|
9
|
+
require_relative "configuration/integrations"
|
|
10
|
+
require_relative "configuration/rate_limiting"
|
|
11
|
+
require_relative "configuration/templates"
|
|
12
|
+
|
|
13
|
+
module RailsOnboarding
|
|
14
|
+
# Every reader/writer here is part of this gem's public configuration DSL
|
|
15
|
+
# (host apps set these in a RailsOnboarding.configure block), so none of
|
|
16
|
+
# them can change shape without breaking every existing installation. The
|
|
17
|
+
# ~40 settings are grouped by feature into the modules under
|
|
18
|
+
# configuration/ - each one owns its own defaults and any logic specific
|
|
19
|
+
# to it, so a change to (say) A/B testing only touches
|
|
20
|
+
# configuration/ab_testing.rb instead of this whole file. What's left
|
|
21
|
+
# here is identity/branding settings with no logic of their own, plus the
|
|
22
|
+
# infrastructure the feature modules share (tenant overrides, the derived-
|
|
23
|
+
# lookup cache, and the validator).
|
|
24
|
+
class Configuration
|
|
25
|
+
include Steps
|
|
26
|
+
include Milestones
|
|
27
|
+
include Tooltips
|
|
28
|
+
include Analytics
|
|
29
|
+
include AbTesting
|
|
30
|
+
include Personalization
|
|
31
|
+
include ProgressiveDisclosure
|
|
32
|
+
include Integrations
|
|
33
|
+
include RateLimiting
|
|
34
|
+
include Templates
|
|
35
|
+
|
|
36
|
+
attr_accessor :user_class_name,
|
|
37
|
+
:include_host_styles,
|
|
38
|
+
:redirect_after_completion,
|
|
39
|
+
:redirect_after_skip,
|
|
40
|
+
:custom_css_path,
|
|
41
|
+
:custom_js_path,
|
|
42
|
+
:welcome_heading,
|
|
43
|
+
:welcome_subheading,
|
|
44
|
+
:welcome_features,
|
|
45
|
+
:admin_user_search
|
|
46
|
+
|
|
47
|
+
def initialize
|
|
48
|
+
@user_class_name = "User"
|
|
49
|
+
@include_host_styles = true # Default to including host app css
|
|
50
|
+
@redirect_after_completion = :root_path
|
|
51
|
+
@redirect_after_skip = :root_path
|
|
52
|
+
@welcome_heading = "We're excited to have you here!"
|
|
53
|
+
@welcome_subheading = "Now, let's take a few moments to get you set up and familiar with a few things you need to know to get started."
|
|
54
|
+
@welcome_features = [
|
|
55
|
+
{ icon: "👤", text: "Set up your profile" },
|
|
56
|
+
{ icon: "📝", text: "Create your first item" },
|
|
57
|
+
{ icon: "🔍", text: "Explore key features" }
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
# Optional override for the admin User Management search box. The
|
|
61
|
+
# built-in search is plain SQL, which can't see through an encrypted
|
|
62
|
+
# email column - a host app that encrypts email and still wants partial
|
|
63
|
+
# matching has to supply the strategy itself. Receives the already
|
|
64
|
+
# status/step-filtered scope and the raw term, and must return a
|
|
65
|
+
# relation (not an array) so sorting and pagination still apply.
|
|
66
|
+
@admin_user_search = nil
|
|
67
|
+
|
|
68
|
+
initialize_steps
|
|
69
|
+
initialize_milestones
|
|
70
|
+
initialize_tooltips
|
|
71
|
+
initialize_analytics
|
|
72
|
+
initialize_ab_testing
|
|
73
|
+
initialize_personalization
|
|
74
|
+
initialize_progressive_disclosure
|
|
75
|
+
initialize_integrations
|
|
76
|
+
initialize_rate_limiting
|
|
77
|
+
initialize_templates
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Validate the current configuration
|
|
81
|
+
# @raise [ConfigurationError] if any validation fails
|
|
82
|
+
def validate!
|
|
83
|
+
validator.validate!
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Check if the configuration is valid without raising
|
|
87
|
+
# @return [Boolean] true if valid, false otherwise
|
|
88
|
+
def valid?
|
|
89
|
+
validator.valid?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Get the validator instance for this configuration
|
|
93
|
+
# @return [ConfigurationValidator]
|
|
94
|
+
def validator
|
|
95
|
+
@validator ||= ConfigurationValidator.new(self)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Get validation errors without raising
|
|
99
|
+
# @return [Array<StandardError>] array of validation errors
|
|
100
|
+
def validation_errors
|
|
101
|
+
validator.errors
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def user_class
|
|
105
|
+
@user_class ||= @user_class_name.constantize
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Clear all cached lookups - call this when configuration changes
|
|
109
|
+
def clear_cache!
|
|
110
|
+
@user_class = nil
|
|
111
|
+
@total_steps = nil
|
|
112
|
+
@step_by_name_cache = nil
|
|
113
|
+
@step_index_cache = nil
|
|
114
|
+
@milestone_by_key_cache = nil
|
|
115
|
+
@milestones_for_trigger_cache = nil
|
|
116
|
+
@validator = nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# Reads a per-request tenant override for +key+, set by
|
|
122
|
+
# MultiTenant.with_tenant_configuration via RailsOnboarding::Current.
|
|
123
|
+
# Falls back to +default+ (the process-wide value) when no override is
|
|
124
|
+
# active for +key+, distinguishing an explicit `false` override from "no
|
|
125
|
+
# override at all".
|
|
126
|
+
def tenant_override(key, default)
|
|
127
|
+
overrides = RailsOnboarding::Current.tenant_overrides
|
|
128
|
+
return default unless overrides&.key?(key)
|
|
129
|
+
|
|
130
|
+
overrides[key]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module RailsOnboarding
|
|
2
|
+
# Base error class for all configuration errors
|
|
3
|
+
class ConfigurationError < StandardError; end
|
|
4
|
+
|
|
5
|
+
# Raised when step configuration is invalid
|
|
6
|
+
class InvalidStepError < ConfigurationError; end
|
|
7
|
+
|
|
8
|
+
# Raised when milestone configuration is invalid
|
|
9
|
+
class InvalidMilestoneError < ConfigurationError; end
|
|
10
|
+
|
|
11
|
+
# Raised when redirect path configuration is invalid
|
|
12
|
+
class InvalidRedirectPathError < ConfigurationError; end
|
|
13
|
+
|
|
14
|
+
# Raised when a configuration value has the wrong type
|
|
15
|
+
class InvalidTypeError < ConfigurationError; end
|
|
16
|
+
|
|
17
|
+
# Raised when a required configuration option is missing
|
|
18
|
+
class MissingRequiredOptionError < ConfigurationError; end
|
|
19
|
+
end
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
require_relative "configuration_errors"
|
|
2
|
+
|
|
3
|
+
module RailsOnboarding
|
|
4
|
+
# Validates RailsOnboarding configuration to ensure all settings are valid
|
|
5
|
+
class ConfigurationValidator
|
|
6
|
+
VALID_TRIGGERS = [ :onboarding_step_completed, :onboarding_completed, :tooltip_shown,
|
|
7
|
+
:tooltip_clicked, :custom ].freeze
|
|
8
|
+
VALID_REDIRECT_TYPES = [ Symbol, String, Proc ].freeze
|
|
9
|
+
VALID_ONBOARDING_REQUIREMENTS = [ :new_users, :all_users, Proc ].freeze
|
|
10
|
+
VALID_API_AUTH_METHODS = [ :token, :session, :custom ].freeze
|
|
11
|
+
VALID_REVEAL_CONDITIONS = [ :time_based, :action_based, :step_based, :milestone_based, :engagement_based ].freeze
|
|
12
|
+
NAME_FORMAT = /\A[a-z_][a-z0-9_]*\z/i
|
|
13
|
+
|
|
14
|
+
attr_reader :config, :errors
|
|
15
|
+
|
|
16
|
+
def initialize(config)
|
|
17
|
+
@config = config
|
|
18
|
+
@errors = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Validate all configuration options
|
|
22
|
+
# @raise [ConfigurationError] if any validation fails
|
|
23
|
+
def validate!
|
|
24
|
+
validate_required_options
|
|
25
|
+
validate_types
|
|
26
|
+
validate_steps
|
|
27
|
+
validate_milestones
|
|
28
|
+
validate_redirect_paths
|
|
29
|
+
validate_feature_tooltips
|
|
30
|
+
validate_ab_tests
|
|
31
|
+
validate_personalized_flows
|
|
32
|
+
validate_progressive_features
|
|
33
|
+
validate_mailer_config
|
|
34
|
+
|
|
35
|
+
raise ConfigurationError, error_message unless errors.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Validate without raising exceptions
|
|
39
|
+
# @return [Boolean] true if valid, false otherwise
|
|
40
|
+
def valid?
|
|
41
|
+
validate!
|
|
42
|
+
true
|
|
43
|
+
rescue ConfigurationError
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def validate_required_options
|
|
50
|
+
validate_present(:user_class_name, "User class name is required")
|
|
51
|
+
validate_present(:steps, "At least one step must be defined")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate_types
|
|
55
|
+
validate_type(:user_class_name, [ String ], "user_class_name must be a String")
|
|
56
|
+
validate_type(:include_host_styles, [ TrueClass, FalseClass ], "include_host_styles must be a Boolean")
|
|
57
|
+
validate_type(:enable_tooltips, [ TrueClass, FalseClass ], "enable_tooltips must be a Boolean")
|
|
58
|
+
validate_type(:enable_milestones, [ TrueClass, FalseClass ], "enable_milestones must be a Boolean")
|
|
59
|
+
validate_type(:enable_analytics, [ TrueClass, FalseClass ], "enable_analytics must be a Boolean")
|
|
60
|
+
validate_type(:steps, [ Array ], "steps must be an Array")
|
|
61
|
+
validate_type(:milestones, [ Array ], "milestones must be an Array")
|
|
62
|
+
validate_type(:feature_tooltips, [ Hash ], "feature_tooltips must be a Hash")
|
|
63
|
+
|
|
64
|
+
# Numeric validations
|
|
65
|
+
if config.analytics_data_retention_days
|
|
66
|
+
validate_type(:analytics_data_retention_days, [ Integer ], "analytics_data_retention_days must be an Integer")
|
|
67
|
+
validate_positive(:analytics_data_retention_days, "analytics_data_retention_days must be positive")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if config.analytics_session_timeout_minutes
|
|
71
|
+
validate_type(:analytics_session_timeout_minutes, [ Integer ], "analytics_session_timeout_minutes must be an Integer")
|
|
72
|
+
validate_positive(:analytics_session_timeout_minutes, "analytics_session_timeout_minutes must be positive")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Validate onboarding_required_for
|
|
76
|
+
unless VALID_ONBOARDING_REQUIREMENTS.include?(config.onboarding_required_for) ||
|
|
77
|
+
config.onboarding_required_for.is_a?(Proc)
|
|
78
|
+
errors << InvalidTypeError.new(
|
|
79
|
+
"onboarding_required_for must be :new_users, :all_users, or a Proc"
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# API mode validations
|
|
84
|
+
if config.api_mode_enabled
|
|
85
|
+
validate_type(:api_mode_enabled, [ TrueClass, FalseClass ], "api_mode_enabled must be a Boolean")
|
|
86
|
+
unless VALID_API_AUTH_METHODS.include?(config.api_authentication_method)
|
|
87
|
+
errors << InvalidTypeError.new(
|
|
88
|
+
"api_authentication_method must be one of: #{VALID_API_AUTH_METHODS.join(', ')}"
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_steps
|
|
95
|
+
return unless config.steps.is_a?(Array)
|
|
96
|
+
|
|
97
|
+
if config.steps.empty?
|
|
98
|
+
errors << InvalidStepError.new("At least one step must be defined")
|
|
99
|
+
return
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
validate_named_collection(
|
|
103
|
+
config.steps,
|
|
104
|
+
error_class: InvalidStepError,
|
|
105
|
+
label: "Step",
|
|
106
|
+
identifier_field: :name,
|
|
107
|
+
identifier_type: [ Symbol, String ],
|
|
108
|
+
duplicate_label: "step name",
|
|
109
|
+
format_description: "Use alphanumeric characters and underscores only, starting with a letter or underscore",
|
|
110
|
+
fields: [
|
|
111
|
+
{ name: :title, type: String },
|
|
112
|
+
{ name: :icon, type: String },
|
|
113
|
+
{ name: :skippable, type: [ TrueClass, FalseClass ] },
|
|
114
|
+
{ name: :path, type: [ Symbol, String, Proc ] },
|
|
115
|
+
{ name: :complete_if, type: Proc }
|
|
116
|
+
]
|
|
117
|
+
) do |step, name|
|
|
118
|
+
# A :path step with no :complete_if and no skippable: true can only
|
|
119
|
+
# advance via an explicit advance_onboarding! call in the host
|
|
120
|
+
# controller - which this validator can't see. Warn rather than error,
|
|
121
|
+
# but make the trap visible: if that call is missing, the user gets
|
|
122
|
+
# redirected to the step's page forever.
|
|
123
|
+
if step.is_a?(Hash) && step[:path] && !step[:complete_if] && step[:skippable] != true
|
|
124
|
+
Rails.logger.warn(
|
|
125
|
+
"RailsOnboarding: step '#{name}' has a :path but no :complete_if and is not skippable. " \
|
|
126
|
+
"Make sure the host controller calls advance_onboarding!(:#{name}), " \
|
|
127
|
+
"or users cannot progress past this step."
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def validate_milestones
|
|
134
|
+
return unless config.enable_milestones
|
|
135
|
+
return unless config.milestones.is_a?(Array)
|
|
136
|
+
return if config.milestones.empty? # Empty milestones array is valid
|
|
137
|
+
|
|
138
|
+
validate_named_collection(
|
|
139
|
+
config.milestones,
|
|
140
|
+
error_class: InvalidMilestoneError,
|
|
141
|
+
label: "Milestone",
|
|
142
|
+
identifier_field: :key,
|
|
143
|
+
duplicate_label: "milestone key",
|
|
144
|
+
format_description: "Use alphanumeric characters and underscores only",
|
|
145
|
+
fields: [
|
|
146
|
+
{ name: :trigger, required: true },
|
|
147
|
+
{ name: :points, type: Integer },
|
|
148
|
+
{ name: :title, type: String },
|
|
149
|
+
{ name: :description, type: String },
|
|
150
|
+
{ name: :conditions, type: Hash }
|
|
151
|
+
]
|
|
152
|
+
) do |milestone, key|
|
|
153
|
+
# Cross-field and value-range checks that a generic field schema
|
|
154
|
+
# can't express - kept bespoke per collection.
|
|
155
|
+
if milestone[:trigger] && !VALID_TRIGGERS.include?(milestone[:trigger].to_sym)
|
|
156
|
+
errors << InvalidMilestoneError.new(
|
|
157
|
+
"Milestone '#{key}' has invalid trigger '#{milestone[:trigger]}'. " \
|
|
158
|
+
"Valid triggers: #{VALID_TRIGGERS.join(', ')}"
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
if milestone[:points].is_a?(Integer) && milestone[:points].negative?
|
|
163
|
+
errors << InvalidMilestoneError.new(
|
|
164
|
+
"Milestone '#{key}' has negative points. Points must be non-negative"
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
if milestone[:conditions].is_a?(Hash) && milestone[:conditions][:step]
|
|
169
|
+
step_name = milestone[:conditions][:step]
|
|
170
|
+
unless config.step_by_name(step_name)
|
|
171
|
+
errors << InvalidMilestoneError.new(
|
|
172
|
+
"Milestone '#{key}' references undefined step '#{step_name}' in conditions"
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def validate_redirect_paths
|
|
180
|
+
validate_redirect_path(:redirect_after_completion, config.redirect_after_completion)
|
|
181
|
+
validate_redirect_path(:redirect_after_skip, config.redirect_after_skip)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def validate_redirect_path(field_name, value)
|
|
185
|
+
return if value.nil?
|
|
186
|
+
|
|
187
|
+
unless VALID_REDIRECT_TYPES.any? { |type| value.is_a?(type) }
|
|
188
|
+
errors << InvalidRedirectPathError.new(
|
|
189
|
+
"#{field_name} must be a Symbol, String, or Proc, got #{value.class}"
|
|
190
|
+
)
|
|
191
|
+
return
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# If it's a symbol, validate it looks like a valid path helper
|
|
195
|
+
if value.is_a?(Symbol) && !value.to_s.end_with?("_path", "_url")
|
|
196
|
+
errors << InvalidRedirectPathError.new(
|
|
197
|
+
"#{field_name} symbol '#{value}' should end with '_path' or '_url' (e.g., :root_path, :dashboard_path)"
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# If it's a string, validate it starts with /
|
|
202
|
+
if value.is_a?(String) && !value.start_with?("/")
|
|
203
|
+
errors << InvalidRedirectPathError.new(
|
|
204
|
+
"#{field_name} string '#{value}' should be an absolute path starting with '/'"
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def validate_feature_tooltips
|
|
210
|
+
return unless config.enable_tooltips
|
|
211
|
+
return unless config.feature_tooltips.is_a?(Hash)
|
|
212
|
+
|
|
213
|
+
config.feature_tooltips.each do |key, tooltip|
|
|
214
|
+
unless tooltip.is_a?(Hash)
|
|
215
|
+
errors << InvalidTypeError.new(
|
|
216
|
+
"Tooltip '#{key}' must be a Hash, got #{tooltip.class}"
|
|
217
|
+
)
|
|
218
|
+
next
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# :text has a combined required+type message ("must have a :text
|
|
222
|
+
# field of type String"), unlike every other field here, so it's
|
|
223
|
+
# kept as its own check rather than going through validate_item_fields.
|
|
224
|
+
unless tooltip[:text].is_a?(String)
|
|
225
|
+
errors << InvalidTypeError.new(
|
|
226
|
+
"Tooltip '#{key}' must have a :text field of type String"
|
|
227
|
+
)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
validate_item_fields(tooltip, "Tooltip '#{key}'", InvalidTypeError, [
|
|
231
|
+
{ name: :delay, type: Integer }
|
|
232
|
+
])
|
|
233
|
+
|
|
234
|
+
if tooltip[:position]
|
|
235
|
+
valid_positions = %w[top bottom left right]
|
|
236
|
+
unless valid_positions.include?(tooltip[:position].to_s)
|
|
237
|
+
errors << InvalidTypeError.new(
|
|
238
|
+
"Tooltip '#{key}' has invalid :position '#{tooltip[:position]}'. " \
|
|
239
|
+
"Valid positions: #{valid_positions.join(', ')}"
|
|
240
|
+
)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def validate_ab_tests
|
|
247
|
+
return unless config.enable_ab_testing
|
|
248
|
+
return unless config.ab_tests.is_a?(Hash)
|
|
249
|
+
|
|
250
|
+
config.ab_tests.each do |test_name, test_config|
|
|
251
|
+
unless test_config.is_a?(Hash)
|
|
252
|
+
errors << InvalidTypeError.new(
|
|
253
|
+
"A/B test '#{test_name}' must be a Hash, got #{test_config.class}"
|
|
254
|
+
)
|
|
255
|
+
next
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Validate variants exist
|
|
259
|
+
unless test_config[:variants].is_a?(Array)
|
|
260
|
+
errors << InvalidTypeError.new(
|
|
261
|
+
"A/B test '#{test_name}' must have a :variants array"
|
|
262
|
+
)
|
|
263
|
+
next
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
if test_config[:variants].empty?
|
|
267
|
+
errors << InvalidTypeError.new(
|
|
268
|
+
"A/B test '#{test_name}' must have at least one variant"
|
|
269
|
+
)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def validate_personalized_flows
|
|
275
|
+
return unless config.personalization_enabled
|
|
276
|
+
return unless config.personalized_flows.is_a?(Hash)
|
|
277
|
+
|
|
278
|
+
config.personalized_flows.each do |user_type, steps|
|
|
279
|
+
unless steps.is_a?(Array)
|
|
280
|
+
errors << InvalidTypeError.new(
|
|
281
|
+
"Personalized flow for '#{user_type}' must be an Array, got #{steps.class}"
|
|
282
|
+
)
|
|
283
|
+
next
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Validate each step follows the same rules as regular steps
|
|
287
|
+
steps.each_with_index do |step, index|
|
|
288
|
+
unless step.is_a?(Hash)
|
|
289
|
+
errors << InvalidStepError.new(
|
|
290
|
+
"Personalized flow '#{user_type}' step at index #{index} must be a Hash"
|
|
291
|
+
)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def validate_progressive_features
|
|
298
|
+
return unless config.progressive_disclosure_enabled
|
|
299
|
+
return unless config.progressive_features.is_a?(Array)
|
|
300
|
+
|
|
301
|
+
validate_named_collection(
|
|
302
|
+
config.progressive_features,
|
|
303
|
+
error_class: InvalidTypeError,
|
|
304
|
+
label: "Progressive feature",
|
|
305
|
+
identifier_field: :key
|
|
306
|
+
) do |feature, key|
|
|
307
|
+
next unless feature[:reveal_condition]
|
|
308
|
+
|
|
309
|
+
unless VALID_REVEAL_CONDITIONS.include?(feature[:reveal_condition])
|
|
310
|
+
errors << InvalidTypeError.new(
|
|
311
|
+
"Progressive feature '#{key}' has invalid :reveal_condition. " \
|
|
312
|
+
"Valid conditions: #{VALID_REVEAL_CONDITIONS.join(', ')}"
|
|
313
|
+
)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Validate time_based features have delay
|
|
317
|
+
if feature[:reveal_condition] == :time_based && !feature[:delay].is_a?(Integer)
|
|
318
|
+
errors << InvalidTypeError.new(
|
|
319
|
+
"Progressive feature '#{key}' with :time_based condition must have :delay (Integer in seconds)"
|
|
320
|
+
)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Validate step_based features reference valid steps
|
|
324
|
+
if feature[:reveal_condition] == :step_based && feature[:after_step]
|
|
325
|
+
unless config.step_by_name(feature[:after_step])
|
|
326
|
+
errors << InvalidTypeError.new(
|
|
327
|
+
"Progressive feature '#{key}' references undefined step '#{feature[:after_step]}'"
|
|
328
|
+
)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def validate_mailer_config
|
|
335
|
+
return unless config.background_jobs_enabled
|
|
336
|
+
|
|
337
|
+
if config.mailer_from && !config.mailer_from.is_a?(String)
|
|
338
|
+
errors << InvalidTypeError.new(
|
|
339
|
+
"mailer_from must be a String, got #{config.mailer_from.class}"
|
|
340
|
+
)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Validate email format
|
|
344
|
+
if config.mailer_from.is_a?(String) && !valid_email_format?(config.mailer_from)
|
|
345
|
+
errors << InvalidTypeError.new(
|
|
346
|
+
"mailer_from '#{config.mailer_from}' is not a valid email address"
|
|
347
|
+
)
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Helper methods
|
|
352
|
+
|
|
353
|
+
def validate_present(field, message)
|
|
354
|
+
value = config.send(field)
|
|
355
|
+
if value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
356
|
+
errors << MissingRequiredOptionError.new(message)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def validate_type(field, valid_types, message)
|
|
361
|
+
value = config.send(field)
|
|
362
|
+
return if value.nil? # nil is valid unless validate_present says otherwise
|
|
363
|
+
|
|
364
|
+
unless valid_types.any? { |type| value.is_a?(type) }
|
|
365
|
+
errors << InvalidTypeError.new(message)
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def validate_positive(field, message)
|
|
370
|
+
value = config.send(field)
|
|
371
|
+
if value && value.is_a?(Integer) && value <= 0
|
|
372
|
+
errors << InvalidTypeError.new(message)
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Shared engine behind validate_steps/validate_milestones/
|
|
377
|
+
# validate_progressive_features: they all iterate an Array of Hashes,
|
|
378
|
+
# each identified by one field (:name/:key), and each with its own mix
|
|
379
|
+
# of required/optional fields - only the identifier's uniqueness/format
|
|
380
|
+
# rules and the field list differ, plus whatever bespoke cross-field
|
|
381
|
+
# checks the caller needs (yielded per item).
|
|
382
|
+
#
|
|
383
|
+
# @param items [Array] the configured collection (steps, milestones, ...)
|
|
384
|
+
# @param error_class [Class] error class to raise for problems found
|
|
385
|
+
# @param label [String] human label used in messages, e.g. "Step"
|
|
386
|
+
# @param identifier_field [Symbol] the field that identifies each item
|
|
387
|
+
# @param identifier_type [Array<Class>, nil] allowed types for the
|
|
388
|
+
# identifier's value, if it should be type-checked
|
|
389
|
+
# @param duplicate_label [String, nil] enables a uniqueness check on the
|
|
390
|
+
# identifier, e.g. "step name"
|
|
391
|
+
# @param format_description [String, nil] enables a name-format check on
|
|
392
|
+
# the identifier (alphanumeric/underscore), described in the message
|
|
393
|
+
# @param fields [Array<Hash>] field schema: {name:, type:, required:}
|
|
394
|
+
# @yield [item, identifier] for bespoke, collection-specific checks once
|
|
395
|
+
# the item has passed the structural checks above
|
|
396
|
+
def validate_named_collection(items, error_class:, label:, identifier_field:, identifier_type: nil,
|
|
397
|
+
duplicate_label: nil, format_description: nil, fields: [])
|
|
398
|
+
return unless items.is_a?(Array)
|
|
399
|
+
|
|
400
|
+
seen_identifiers = []
|
|
401
|
+
|
|
402
|
+
items.each_with_index do |item, index|
|
|
403
|
+
unless item.is_a?(Hash)
|
|
404
|
+
errors << error_class.new("#{label} at index #{index} must be a Hash")
|
|
405
|
+
next
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
identifier = item[identifier_field]
|
|
409
|
+
unless identifier
|
|
410
|
+
errors << error_class.new("#{label} at index #{index} is missing required :#{identifier_field} field")
|
|
411
|
+
next
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
if identifier_type && !identifier_type.any? { |type| identifier.is_a?(type) }
|
|
415
|
+
errors << error_class.new(
|
|
416
|
+
"#{label} at index #{index} has invalid #{identifier_field} type. " \
|
|
417
|
+
"Must be #{identifier_type.map(&:name).join(' or ')}, got #{identifier.class}"
|
|
418
|
+
)
|
|
419
|
+
next
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
item_label = "#{label} '#{identifier}'"
|
|
423
|
+
|
|
424
|
+
if duplicate_label
|
|
425
|
+
if seen_identifiers.include?(identifier.to_sym)
|
|
426
|
+
errors << error_class.new("Duplicate #{duplicate_label} found: '#{identifier}'")
|
|
427
|
+
end
|
|
428
|
+
seen_identifiers << identifier.to_sym
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
if format_description && !identifier.to_s.match?(NAME_FORMAT)
|
|
432
|
+
errors << error_class.new("#{item_label} has invalid format. #{format_description}")
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
validate_item_fields(item, item_label, error_class, fields)
|
|
436
|
+
|
|
437
|
+
yield item, identifier if block_given?
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Checks a schema of {name:, type:, required:} field rules against a
|
|
442
|
+
# single Hash item, reporting a missing-required-field or wrong-type
|
|
443
|
+
# error per field as appropriate.
|
|
444
|
+
def validate_item_fields(item, item_label, error_class, fields)
|
|
445
|
+
fields.each do |field|
|
|
446
|
+
value = item[field[:name]]
|
|
447
|
+
|
|
448
|
+
if field[:required] && value.nil?
|
|
449
|
+
errors << error_class.new("#{item_label} is missing required :#{field[:name]} field")
|
|
450
|
+
next
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
next if value.nil?
|
|
454
|
+
|
|
455
|
+
expected_types = Array(field[:type])
|
|
456
|
+
next if expected_types.empty?
|
|
457
|
+
|
|
458
|
+
unless expected_types.any? { |type| value.is_a?(type) }
|
|
459
|
+
errors << error_class.new(
|
|
460
|
+
"#{item_label} has invalid :#{field[:name]} type. " \
|
|
461
|
+
"Expected #{expected_types.map(&:name).join(' or ')}, got #{value.class}"
|
|
462
|
+
)
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def valid_email_format?(email)
|
|
468
|
+
email.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def error_message
|
|
472
|
+
header = "Configuration validation failed with #{errors.size} error(s):"
|
|
473
|
+
error_list = errors.map.with_index { |error, i| " #{i + 1}. #{error.message}" }.join("\n")
|
|
474
|
+
"#{header}\n#{error_list}"
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
end
|