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,173 @@
|
|
|
1
|
+
module RailsOnboarding
|
|
2
|
+
# Error Recovery Service
|
|
3
|
+
# Handles failed steps gracefully with retry logic and error state management
|
|
4
|
+
class ErrorRecovery
|
|
5
|
+
MAX_RETRIES = 3
|
|
6
|
+
RETRY_DELAY = 2.seconds
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
# Execute a block with error recovery
|
|
10
|
+
#
|
|
11
|
+
# @param user [User] The user performing the action
|
|
12
|
+
# @param action [Symbol] The action being performed
|
|
13
|
+
# @param max_retries [Integer] Maximum number of retries
|
|
14
|
+
# @yield The block to execute
|
|
15
|
+
# @return [Boolean] True if successful, false otherwise
|
|
16
|
+
def with_recovery(user, action, max_retries: MAX_RETRIES, &block)
|
|
17
|
+
attempt = 0
|
|
18
|
+
last_error = nil
|
|
19
|
+
|
|
20
|
+
loop do
|
|
21
|
+
attempt += 1
|
|
22
|
+
|
|
23
|
+
begin
|
|
24
|
+
result = block.call
|
|
25
|
+
clear_error_state(user, action)
|
|
26
|
+
return result
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
last_error = e
|
|
29
|
+
Rails.logger.error("Error in #{action} (attempt #{attempt}/#{max_retries}): #{e.message}")
|
|
30
|
+
Rails.logger.error(e.backtrace.join("\n")) if Rails.env.development?
|
|
31
|
+
|
|
32
|
+
record_error(user, action, e, attempt)
|
|
33
|
+
|
|
34
|
+
break if attempt >= max_retries
|
|
35
|
+
|
|
36
|
+
sleep RETRY_DELAY if defined?(sleep)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# All retries failed
|
|
41
|
+
mark_as_failed(user, action, last_error)
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Record an error for analytics and debugging
|
|
46
|
+
def record_error(user, action, error, attempt)
|
|
47
|
+
return unless user && error
|
|
48
|
+
|
|
49
|
+
error_data = {
|
|
50
|
+
action: action.to_s,
|
|
51
|
+
error_class: error.class.name,
|
|
52
|
+
error_message: error.message,
|
|
53
|
+
attempt: attempt,
|
|
54
|
+
timestamp: Time.current.iso8601
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Store in user's error log if available
|
|
58
|
+
if user.respond_to?(:onboarding_errors)
|
|
59
|
+
user.onboarding_errors ||= []
|
|
60
|
+
user.onboarding_errors << error_data
|
|
61
|
+
user.save(validate: false) rescue nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Track in analytics
|
|
65
|
+
if defined?(RailsOnboarding::AnalyticsEvent)
|
|
66
|
+
RailsOnboarding::AnalyticsEvent.track_custom_event(
|
|
67
|
+
user: user,
|
|
68
|
+
event_name: "onboarding_error",
|
|
69
|
+
event_data: error_data
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Mark an action as failed after all retries
|
|
75
|
+
def mark_as_failed(user, action, error)
|
|
76
|
+
return unless user
|
|
77
|
+
|
|
78
|
+
if user.respond_to?(:onboarding_failed_actions)
|
|
79
|
+
user.onboarding_failed_actions ||= []
|
|
80
|
+
user.onboarding_failed_actions << {
|
|
81
|
+
action: action.to_s,
|
|
82
|
+
error: error&.message,
|
|
83
|
+
failed_at: Time.current.iso8601
|
|
84
|
+
}
|
|
85
|
+
user.save(validate: false) rescue nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
Rails.logger.error("Action #{action} failed for user #{user.id} after all retries")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Clear error state for a successful action
|
|
92
|
+
def clear_error_state(user, action)
|
|
93
|
+
return unless user
|
|
94
|
+
|
|
95
|
+
if user.respond_to?(:onboarding_failed_actions)
|
|
96
|
+
user.onboarding_failed_actions ||= []
|
|
97
|
+
user.onboarding_failed_actions.reject! { |a| a[:action] == action.to_s }
|
|
98
|
+
user.save(validate: false) rescue nil
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Check if user has any failed actions
|
|
103
|
+
def has_errors?(user)
|
|
104
|
+
return false unless user&.respond_to?(:onboarding_failed_actions)
|
|
105
|
+
user.onboarding_failed_actions&.any? || false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Get failed actions for a user
|
|
109
|
+
def failed_actions(user)
|
|
110
|
+
return [] unless user&.respond_to?(:onboarding_failed_actions)
|
|
111
|
+
user.onboarding_failed_actions || []
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Retry a failed action
|
|
115
|
+
def retry_failed_action(user, action, &block)
|
|
116
|
+
clear_error_state(user, action)
|
|
117
|
+
with_recovery(user, action, &block)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Reset all errors for a user
|
|
121
|
+
def reset_errors(user)
|
|
122
|
+
return unless user
|
|
123
|
+
|
|
124
|
+
if user.respond_to?(:onboarding_errors=)
|
|
125
|
+
user.onboarding_errors = []
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
if user.respond_to?(:onboarding_failed_actions=)
|
|
129
|
+
user.onboarding_failed_actions = []
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
user.save(validate: false) rescue nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Retry with exponential backoff
|
|
136
|
+
def with_exponential_backoff(max_attempts: 3, base_delay: 1, max_delay: 30, exponential_base: 2)
|
|
137
|
+
attempt = 0
|
|
138
|
+
begin
|
|
139
|
+
attempt += 1
|
|
140
|
+
yield
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
if attempt < max_attempts
|
|
143
|
+
delay = [ base_delay * (exponential_base ** (attempt - 1)), max_delay ].min
|
|
144
|
+
Rails.logger.warn "Attempt #{attempt} failed: #{e.message}. Retrying in #{delay}s..."
|
|
145
|
+
sleep delay
|
|
146
|
+
retry
|
|
147
|
+
else
|
|
148
|
+
Rails.logger.error "All #{max_attempts} attempts failed: #{e.message}"
|
|
149
|
+
raise
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Execute with timeout
|
|
155
|
+
def with_timeout(seconds: 30)
|
|
156
|
+
Timeout.timeout(seconds) do
|
|
157
|
+
yield
|
|
158
|
+
end
|
|
159
|
+
rescue Timeout::Error => e
|
|
160
|
+
Rails.logger.error "Operation timed out after #{seconds} seconds"
|
|
161
|
+
raise
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Execute with fallback value
|
|
165
|
+
def with_fallback(fallback_value = nil)
|
|
166
|
+
yield
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
Rails.logger.warn "Operation failed, using fallback: #{e.message}"
|
|
169
|
+
fallback_value
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsOnboarding
|
|
4
|
+
# Lazy Loading module for performance optimization
|
|
5
|
+
#
|
|
6
|
+
# This module provides helpers for lazy loading onboarding components
|
|
7
|
+
# to improve initial page load times and reduce unnecessary processing
|
|
8
|
+
module LazyLoading
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
included do
|
|
12
|
+
# Class-level configuration for lazy loading
|
|
13
|
+
class_attribute :lazy_loading_enabled, default: true
|
|
14
|
+
class_attribute :lazy_load_threshold, default: 1000 # Load data only when user count < threshold
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class_methods do
|
|
18
|
+
# Scope for users who need onboarding (optimized query)
|
|
19
|
+
#
|
|
20
|
+
# @return [ActiveRecord::Relation]
|
|
21
|
+
def needs_onboarding
|
|
22
|
+
where(onboarding_completed: false)
|
|
23
|
+
.where("created_at > ?", 1.hour.ago)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Scope for users currently in onboarding
|
|
27
|
+
#
|
|
28
|
+
# @return [ActiveRecord::Relation]
|
|
29
|
+
def in_onboarding
|
|
30
|
+
where(onboarding_completed: false)
|
|
31
|
+
.where.not(onboarding_current_step: nil)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Batch load onboarding states for multiple users
|
|
35
|
+
# Reduces N+1 queries when displaying lists
|
|
36
|
+
#
|
|
37
|
+
# @param user_ids [Array<Integer>] User IDs to load
|
|
38
|
+
# @return [Hash] Hash of user_id => onboarding_state
|
|
39
|
+
def batch_load_onboarding_states(user_ids)
|
|
40
|
+
return {} if user_ids.empty?
|
|
41
|
+
|
|
42
|
+
where(id: user_ids)
|
|
43
|
+
.pluck(:id, :onboarding_completed, :onboarding_current_step)
|
|
44
|
+
.each_with_object({}) do |(id, completed, step), hash|
|
|
45
|
+
hash[id] = {
|
|
46
|
+
completed: completed,
|
|
47
|
+
current_step: step
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Count users in each onboarding step (cached for performance)
|
|
53
|
+
#
|
|
54
|
+
# @param ttl [Integer] Cache TTL in seconds
|
|
55
|
+
# @return [Hash] Step name => count
|
|
56
|
+
def onboarding_step_counts(ttl: 300)
|
|
57
|
+
cache_key = "rails_onboarding:step_counts"
|
|
58
|
+
Rails.cache.fetch(cache_key, expires_in: ttl) do
|
|
59
|
+
in_onboarding
|
|
60
|
+
.group(:onboarding_current_step)
|
|
61
|
+
.count
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Lazy load current step data only when needed
|
|
67
|
+
#
|
|
68
|
+
# @return [Hash, nil] Step data
|
|
69
|
+
def lazy_current_step
|
|
70
|
+
return @lazy_current_step if defined?(@lazy_current_step)
|
|
71
|
+
|
|
72
|
+
@lazy_current_step = if lazy_load_enabled?
|
|
73
|
+
current_onboarding_step
|
|
74
|
+
else
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Lazy load next step data only when needed
|
|
80
|
+
#
|
|
81
|
+
# @return [Hash, nil] Next step data
|
|
82
|
+
def lazy_next_step
|
|
83
|
+
return @lazy_next_step if defined?(@lazy_next_step)
|
|
84
|
+
|
|
85
|
+
@lazy_next_step = if lazy_load_enabled?
|
|
86
|
+
next_onboarding_step
|
|
87
|
+
else
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Lazy load available milestones
|
|
93
|
+
#
|
|
94
|
+
# @return [Array<Hash>] Available milestones
|
|
95
|
+
def lazy_milestones_available
|
|
96
|
+
return @lazy_milestones_available if defined?(@lazy_milestones_available)
|
|
97
|
+
|
|
98
|
+
@lazy_milestones_available = if lazy_load_enabled? && RailsOnboarding.configuration.enable_milestones
|
|
99
|
+
milestones_available
|
|
100
|
+
else
|
|
101
|
+
[]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Lazy load tooltips only when on a page that needs them
|
|
106
|
+
#
|
|
107
|
+
# @param page_context [String] Current page or controller action
|
|
108
|
+
# @return [Hash] Available tooltips for current page
|
|
109
|
+
def lazy_tooltips_for_page(page_context)
|
|
110
|
+
return {} unless lazy_load_enabled?
|
|
111
|
+
return {} unless RailsOnboarding.configuration.enable_tooltips
|
|
112
|
+
|
|
113
|
+
cache_key = "rails_onboarding:user:#{id}:tooltips:#{page_context}"
|
|
114
|
+
Rails.cache.fetch(cache_key, expires_in: 600) do
|
|
115
|
+
tooltips = {}
|
|
116
|
+
RailsOnboarding.configuration.feature_tooltips.each do |feature, config|
|
|
117
|
+
# Only include tooltips relevant to current page
|
|
118
|
+
if feature.include?(page_context) && show_feature_tooltip?(feature)
|
|
119
|
+
tooltips[feature] = config
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
tooltips
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Check if lazy loading should be enabled for this user
|
|
127
|
+
# based on both the enabled flag and user count threshold
|
|
128
|
+
#
|
|
129
|
+
# @return [Boolean]
|
|
130
|
+
def lazy_load_enabled?
|
|
131
|
+
return false unless self.class.lazy_loading_enabled
|
|
132
|
+
|
|
133
|
+
# Use ActiveRecord's count with uncached query to ensure fresh count
|
|
134
|
+
# This works better with Rails test transactions
|
|
135
|
+
count = self.class.uncached { self.class.count }
|
|
136
|
+
|
|
137
|
+
count < self.class.lazy_load_threshold
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Preload onboarding data for this user
|
|
141
|
+
# Call this in controller before rendering to avoid N+1
|
|
142
|
+
#
|
|
143
|
+
# @return [Hash] Preloaded data
|
|
144
|
+
def preload_onboarding_data
|
|
145
|
+
{
|
|
146
|
+
needs_onboarding: needs_onboarding?,
|
|
147
|
+
current_step: current_onboarding_step,
|
|
148
|
+
next_step: next_onboarding_step,
|
|
149
|
+
progress: onboarding_progress,
|
|
150
|
+
milestones: RailsOnboarding.configuration.enable_milestones ? achieved_milestones : [],
|
|
151
|
+
milestone_points: RailsOnboarding.configuration.enable_milestones ? total_milestone_points : 0
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
module RailsOnboarding
|
|
2
|
+
# Multi-Tenant Support
|
|
3
|
+
# Allows different onboarding configurations per organization/tenant
|
|
4
|
+
class MultiTenant
|
|
5
|
+
class << self
|
|
6
|
+
# Storage for organization-specific configurations (by ID)
|
|
7
|
+
def organization_configurations
|
|
8
|
+
@organization_configurations ||= {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Merged configuration is cached per organization ID so repeated lookups
|
|
12
|
+
# (every onboarding request) don't re-merge the same hash every time.
|
|
13
|
+
# Invalidated whenever that organization is reconfigured or cleared.
|
|
14
|
+
def merged_configuration_cache
|
|
15
|
+
@merged_configuration_cache ||= {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Configure onboarding for a specific organization by ID
|
|
19
|
+
#
|
|
20
|
+
# @param organization_id [Integer, String] The organization ID
|
|
21
|
+
# @yield [config] Configuration object to customize
|
|
22
|
+
def configure_for_organization(organization_id)
|
|
23
|
+
config = OrganizationConfig.new(organization_id)
|
|
24
|
+
yield config if block_given?
|
|
25
|
+
organization_configurations[organization_id] = config.to_hash
|
|
26
|
+
merged_configuration_cache.delete(organization_id)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Clear all organization configurations (useful for testing)
|
|
30
|
+
def clear_all_configurations
|
|
31
|
+
@organization_configurations = {}
|
|
32
|
+
@merged_configuration_cache = {}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Get configuration for a specific tenant
|
|
36
|
+
#
|
|
37
|
+
# @param tenant [Object, Integer, String] The tenant object or organization ID
|
|
38
|
+
# @return [Hash] Tenant-specific configuration
|
|
39
|
+
def configuration_for(tenant)
|
|
40
|
+
return default_configuration unless tenant
|
|
41
|
+
|
|
42
|
+
# Check if tenant is an ID (for organization_configurations lookup)
|
|
43
|
+
if tenant.is_a?(Integer) || tenant.is_a?(String)
|
|
44
|
+
return merged_configuration_cache[tenant] ||= begin
|
|
45
|
+
stored_config = organization_configurations[tenant]
|
|
46
|
+
stored_config ? merge_configurations(default_configuration, stored_config) : default_configuration
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Check if tenant has custom configuration
|
|
51
|
+
tenant_config = if tenant.respond_to?(:onboarding_configuration)
|
|
52
|
+
tenant.onboarding_configuration
|
|
53
|
+
elsif tenant.respond_to?(:onboarding_config)
|
|
54
|
+
tenant.onboarding_config
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
return default_configuration unless tenant_config
|
|
58
|
+
|
|
59
|
+
# Merge with default configuration
|
|
60
|
+
merge_configurations(default_configuration, parse_config(tenant_config))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Get steps for a specific tenant
|
|
64
|
+
#
|
|
65
|
+
# @param tenant [Object] The tenant object
|
|
66
|
+
# @return [Array<Hash>] Array of step configurations
|
|
67
|
+
def steps_for(tenant)
|
|
68
|
+
config = configuration_for(tenant)
|
|
69
|
+
config[:steps] || RailsOnboarding.configuration.steps
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Get feature tooltips for a specific tenant
|
|
73
|
+
#
|
|
74
|
+
# @param tenant [Object] The tenant object
|
|
75
|
+
# @return [Hash] Feature tooltip configurations
|
|
76
|
+
def tooltips_for(tenant)
|
|
77
|
+
config = configuration_for(tenant)
|
|
78
|
+
config[:feature_tooltips] || RailsOnboarding.configuration.feature_tooltips
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Get milestones for a specific tenant
|
|
82
|
+
#
|
|
83
|
+
# @param tenant [Object] The tenant object
|
|
84
|
+
# @return [Array<Hash>] Milestone configurations
|
|
85
|
+
def milestones_for(tenant)
|
|
86
|
+
config = configuration_for(tenant)
|
|
87
|
+
config[:milestones] || RailsOnboarding.configuration.milestones
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Check if a feature is enabled for a tenant
|
|
91
|
+
#
|
|
92
|
+
# @param tenant [Object] The tenant object
|
|
93
|
+
# @param feature [Symbol] The feature to check
|
|
94
|
+
# @return [Boolean] True if feature is enabled
|
|
95
|
+
def feature_enabled?(tenant, feature)
|
|
96
|
+
config = configuration_for(tenant)
|
|
97
|
+
|
|
98
|
+
case feature
|
|
99
|
+
when :tooltips
|
|
100
|
+
config[:enable_tooltips] != false
|
|
101
|
+
when :milestones
|
|
102
|
+
config[:enable_milestones] != false
|
|
103
|
+
when :analytics
|
|
104
|
+
config[:enable_analytics] != false
|
|
105
|
+
when :ab_testing
|
|
106
|
+
config[:enable_ab_testing] == true
|
|
107
|
+
when :personalization
|
|
108
|
+
config[:personalization_enabled] == true
|
|
109
|
+
else
|
|
110
|
+
false
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Apply tenant configuration to current context, for the duration of the
|
|
115
|
+
# block only. This does NOT touch RailsOnboarding.configuration itself -
|
|
116
|
+
# it sets a thread/fiber-local override (RailsOnboarding::Current) that
|
|
117
|
+
# Configuration's readers consult first, so concurrent requests for
|
|
118
|
+
# other tenants (or no tenant) are never affected, and everything is
|
|
119
|
+
# automatically cleared even if this method's own `ensure` never ran
|
|
120
|
+
# (Rails resets CurrentAttributes around every request and job).
|
|
121
|
+
#
|
|
122
|
+
# @param tenant [Object] The tenant object
|
|
123
|
+
# @yield Block to execute with tenant configuration
|
|
124
|
+
def with_tenant_configuration(tenant)
|
|
125
|
+
return yield unless tenant
|
|
126
|
+
|
|
127
|
+
previous_overrides = RailsOnboarding::Current.tenant_overrides
|
|
128
|
+
RailsOnboarding::Current.tenant_overrides = tenant_config_overrides(tenant)
|
|
129
|
+
|
|
130
|
+
yield
|
|
131
|
+
ensure
|
|
132
|
+
RailsOnboarding::Current.tenant_overrides = previous_overrides
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Get organization ID for user
|
|
136
|
+
#
|
|
137
|
+
# @param user [User] The user object
|
|
138
|
+
# @return [Integer, String, nil] The organization ID
|
|
139
|
+
def organization_for_user(user)
|
|
140
|
+
return nil unless user
|
|
141
|
+
|
|
142
|
+
# Try to get organization_id directly
|
|
143
|
+
if user.respond_to?(:organization_id)
|
|
144
|
+
return user.organization_id
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Otherwise, get tenant and extract its ID
|
|
148
|
+
tenant = tenant_from_user(user)
|
|
149
|
+
tenant&.id
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Get tenant from user
|
|
153
|
+
#
|
|
154
|
+
# @param user [User] The user object
|
|
155
|
+
# @return [Object, nil] The tenant object
|
|
156
|
+
def tenant_from_user(user)
|
|
157
|
+
return nil unless user
|
|
158
|
+
|
|
159
|
+
# Try common tenant associations
|
|
160
|
+
if user.respond_to?(:organization)
|
|
161
|
+
user.organization
|
|
162
|
+
elsif user.respond_to?(:account)
|
|
163
|
+
user.account
|
|
164
|
+
elsif user.respond_to?(:tenant)
|
|
165
|
+
user.tenant
|
|
166
|
+
elsif user.respond_to?(:team)
|
|
167
|
+
user.team
|
|
168
|
+
elsif user.respond_to?(:company)
|
|
169
|
+
user.company
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Set custom configuration for a tenant
|
|
174
|
+
#
|
|
175
|
+
# @param tenant [Object] The tenant object
|
|
176
|
+
# @param config [Hash] Configuration hash
|
|
177
|
+
def set_configuration(tenant, config)
|
|
178
|
+
return unless tenant
|
|
179
|
+
|
|
180
|
+
if tenant.respond_to?(:onboarding_configuration=)
|
|
181
|
+
tenant.onboarding_configuration = config.to_json
|
|
182
|
+
tenant.save
|
|
183
|
+
elsif tenant.respond_to?(:update)
|
|
184
|
+
tenant.update(onboarding_configuration: config.to_json) rescue nil
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Copy configuration from one tenant to another
|
|
189
|
+
#
|
|
190
|
+
# @param source_tenant [Object] Source tenant
|
|
191
|
+
# @param target_tenant [Object] Target tenant
|
|
192
|
+
def copy_configuration(source_tenant, target_tenant)
|
|
193
|
+
config = configuration_for(source_tenant)
|
|
194
|
+
set_configuration(target_tenant, config)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Reset tenant configuration to default
|
|
198
|
+
#
|
|
199
|
+
# @param tenant [Object] The tenant object
|
|
200
|
+
def reset_configuration(tenant)
|
|
201
|
+
return unless tenant
|
|
202
|
+
|
|
203
|
+
if tenant.respond_to?(:onboarding_configuration=)
|
|
204
|
+
tenant.onboarding_configuration = nil
|
|
205
|
+
tenant.save
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
private
|
|
210
|
+
|
|
211
|
+
def default_configuration
|
|
212
|
+
{
|
|
213
|
+
steps: RailsOnboarding.configuration.steps,
|
|
214
|
+
feature_tooltips: RailsOnboarding.configuration.feature_tooltips,
|
|
215
|
+
milestones: RailsOnboarding.configuration.milestones,
|
|
216
|
+
enable_tooltips: RailsOnboarding.configuration.enable_tooltips,
|
|
217
|
+
enable_milestones: RailsOnboarding.configuration.enable_milestones,
|
|
218
|
+
enable_analytics: RailsOnboarding.configuration.enable_analytics,
|
|
219
|
+
enable_ab_testing: RailsOnboarding.configuration.enable_ab_testing,
|
|
220
|
+
personalization_enabled: RailsOnboarding.configuration.personalization_enabled,
|
|
221
|
+
redirect_after_completion: RailsOnboarding.configuration.redirect_after_completion,
|
|
222
|
+
redirect_after_skip: RailsOnboarding.configuration.redirect_after_skip
|
|
223
|
+
}
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def parse_config(config_data)
|
|
227
|
+
return {} unless config_data
|
|
228
|
+
|
|
229
|
+
parsed = if config_data.is_a?(String)
|
|
230
|
+
JSON.parse(config_data)
|
|
231
|
+
else
|
|
232
|
+
config_data
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
parsed.deep_symbolize_keys
|
|
236
|
+
rescue JSON::ParserError => e
|
|
237
|
+
Rails.logger.error("Failed to parse tenant configuration: #{e.message}")
|
|
238
|
+
{}
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def merge_configurations(base, override)
|
|
242
|
+
base.deep_merge(override) do |key, base_val, override_val|
|
|
243
|
+
# For arrays, use override completely
|
|
244
|
+
if base_val.is_a?(Array) && override_val.is_a?(Array)
|
|
245
|
+
override_val
|
|
246
|
+
else
|
|
247
|
+
override_val.nil? ? base_val : override_val
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Builds the RailsOnboarding::Current override hash for +tenant+, only
|
|
253
|
+
# including keys that should actually override the process-wide config -
|
|
254
|
+
# mirrors the guards the old mutation-based implementation used, so a
|
|
255
|
+
# tenant config that hasn't customized a given setting still falls
|
|
256
|
+
# through to the global default instead of overriding it with nil/false.
|
|
257
|
+
def tenant_config_overrides(tenant)
|
|
258
|
+
config = configuration_for(tenant)
|
|
259
|
+
overrides = {}
|
|
260
|
+
|
|
261
|
+
overrides[:steps] = config[:steps] if config[:steps]
|
|
262
|
+
overrides[:feature_tooltips] = config[:feature_tooltips] if config[:feature_tooltips]
|
|
263
|
+
overrides[:milestones] = config[:milestones] if config[:milestones]
|
|
264
|
+
overrides[:enable_tooltips] = config[:enable_tooltips] unless config[:enable_tooltips].nil?
|
|
265
|
+
overrides[:enable_milestones] = config[:enable_milestones] unless config[:enable_milestones].nil?
|
|
266
|
+
|
|
267
|
+
overrides
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Configuration object for organization-specific settings
|
|
273
|
+
class OrganizationConfig
|
|
274
|
+
attr_accessor :steps, :feature_tooltips, :milestones,
|
|
275
|
+
:enable_tooltips, :enable_milestones, :enable_analytics,
|
|
276
|
+
:enable_ab_testing, :personalization_enabled,
|
|
277
|
+
:redirect_after_completion, :redirect_after_skip
|
|
278
|
+
|
|
279
|
+
def initialize(organization_id)
|
|
280
|
+
@organization_id = organization_id
|
|
281
|
+
@steps = []
|
|
282
|
+
@feature_tooltips = {}
|
|
283
|
+
@milestones = {}
|
|
284
|
+
@enable_tooltips = true
|
|
285
|
+
@enable_milestones = true
|
|
286
|
+
@enable_analytics = true
|
|
287
|
+
@enable_ab_testing = false
|
|
288
|
+
@personalization_enabled = false
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def to_hash
|
|
292
|
+
{
|
|
293
|
+
steps: @steps,
|
|
294
|
+
feature_tooltips: @feature_tooltips,
|
|
295
|
+
milestones: @milestones,
|
|
296
|
+
enable_tooltips: @enable_tooltips,
|
|
297
|
+
enable_milestones: @enable_milestones,
|
|
298
|
+
enable_analytics: @enable_analytics,
|
|
299
|
+
enable_ab_testing: @enable_ab_testing,
|
|
300
|
+
personalization_enabled: @personalization_enabled,
|
|
301
|
+
redirect_after_completion: @redirect_after_completion,
|
|
302
|
+
redirect_after_skip: @redirect_after_skip
|
|
303
|
+
}
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|