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,409 @@
|
|
|
1
|
+
module RailsOnboarding
|
|
2
|
+
class OnboardingController < ApplicationController
|
|
3
|
+
include RailsOnboarding::RateLimitable
|
|
4
|
+
|
|
5
|
+
before_action :authenticate_user!
|
|
6
|
+
before_action :check_onboarding_status, except: [ :complete, :skip, :restart ]
|
|
7
|
+
before_action :set_step
|
|
8
|
+
|
|
9
|
+
# Error handling for common scenarios.
|
|
10
|
+
# rescue_from handlers are checked most-specific-last-registered-first, so
|
|
11
|
+
# the generic StandardError handler must come first or it will swallow
|
|
12
|
+
# RecordInvalid/RecordNotFound too (they're both StandardError subclasses)
|
|
13
|
+
# and the specific handlers below become dead code.
|
|
14
|
+
rescue_from StandardError, with: :handle_standard_error
|
|
15
|
+
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
|
|
16
|
+
rescue_from ActiveRecord::RecordInvalid, with: :handle_validation_error
|
|
17
|
+
|
|
18
|
+
def show
|
|
19
|
+
unless @current_step
|
|
20
|
+
# Reset onboarding to first step
|
|
21
|
+
first_step = RailsOnboarding.configuration.steps.first
|
|
22
|
+
|
|
23
|
+
unless first_step
|
|
24
|
+
handle_configuration_error("No onboarding steps configured")
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
current_user.update!(onboarding_current_step: first_step[:name])
|
|
29
|
+
@current_step = current_user.current_onboarding_step
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Reset invalid step to first step
|
|
33
|
+
if current_user.onboarding_current_step &&
|
|
34
|
+
!RailsOnboarding.configuration.step_by_name(current_user.onboarding_current_step)
|
|
35
|
+
first_step = RailsOnboarding.configuration.steps.first
|
|
36
|
+
current_user.update!(onboarding_current_step: first_step[:name])
|
|
37
|
+
@current_step = current_user.current_onboarding_step
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Skip past steps whose :complete_if criteria are already satisfied.
|
|
41
|
+
# This is what lets a step live on a real host-app page: the host
|
|
42
|
+
# controller never has to know onboarding exists - landing back on
|
|
43
|
+
# /onboarding re-checks and moves on. May redirect (completion).
|
|
44
|
+
advance_completed_steps
|
|
45
|
+
return if performed?
|
|
46
|
+
|
|
47
|
+
# Track step entry - the funnel's "reached this step" signal. Emitted
|
|
48
|
+
# only on a user's first entry to a step, so refreshes and back-navigation
|
|
49
|
+
# don't record duplicate events.
|
|
50
|
+
if RailsOnboarding.configuration.enable_analytics && first_entry_to_step?(@current_step[:name])
|
|
51
|
+
RailsOnboarding::AnalyticsEvent.track_step_started(
|
|
52
|
+
user: current_user,
|
|
53
|
+
step_name: @current_step[:name],
|
|
54
|
+
step_index: RailsOnboarding.configuration.step_index(@current_step[:name])
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Steps that own a real page in the host app: route there instead of
|
|
59
|
+
# rendering a gem template, so the existing controller/view do the work.
|
|
60
|
+
if @current_step[:path]
|
|
61
|
+
redirect_to_step_page and return
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Dynamic action based on current step
|
|
65
|
+
step_template = @current_step[:name]
|
|
66
|
+
|
|
67
|
+
# Render the appropriate template
|
|
68
|
+
if template_exists?(step_template)
|
|
69
|
+
render step_template
|
|
70
|
+
else
|
|
71
|
+
render :step
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def next
|
|
76
|
+
unless @current_step
|
|
77
|
+
respond_to do |format|
|
|
78
|
+
format.html { redirect_to onboarding_path, alert: "Invalid step. Redirecting to current step." }
|
|
79
|
+
format.turbo_stream do
|
|
80
|
+
render turbo_stream: turbo_stream.replace(
|
|
81
|
+
"flash-messages",
|
|
82
|
+
partial: "rails_onboarding/shared/flash",
|
|
83
|
+
locals: { alert: "Invalid step. Please try again." }
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
return
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
if params[:step_data].present?
|
|
91
|
+
# Process any step-specific data
|
|
92
|
+
process_step_data(@current_step[:name], params[:step_data])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Award milestones for completing this step
|
|
96
|
+
awarded_milestones = []
|
|
97
|
+
if defined?(RailsOnboarding::MilestoneService)
|
|
98
|
+
awarded_milestones = RailsOnboarding::MilestoneService.check_onboarding_step_milestones(
|
|
99
|
+
current_user,
|
|
100
|
+
@current_step[:name]
|
|
101
|
+
) || []
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
current_user.complete_onboarding_step!(@current_step[:name])
|
|
105
|
+
|
|
106
|
+
if current_user.onboarding_completed?
|
|
107
|
+
# Award completion milestones
|
|
108
|
+
if defined?(RailsOnboarding::MilestoneService)
|
|
109
|
+
completion_milestones = RailsOnboarding::MilestoneService.check_onboarding_completion_milestones(current_user) || []
|
|
110
|
+
awarded_milestones.concat(completion_milestones)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
redirect_to_after_completion(awarded_milestones)
|
|
114
|
+
else
|
|
115
|
+
redirect_to onboarding_path(awarded_milestones: awarded_milestones.map { |m| m[:key] })
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def complete
|
|
120
|
+
completion_milestones = []
|
|
121
|
+
if defined?(RailsOnboarding::MilestoneService)
|
|
122
|
+
completion_milestones = RailsOnboarding::MilestoneService.check_onboarding_completion_milestones(current_user) || []
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
current_user.complete_onboarding!
|
|
126
|
+
|
|
127
|
+
redirect_to_after_completion(completion_milestones)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def skip
|
|
131
|
+
if params[:skip_all] == "true"
|
|
132
|
+
current_user.skip_onboarding!
|
|
133
|
+
redirect_to_after_skip
|
|
134
|
+
elsif @current_step && @current_step[:skippable]
|
|
135
|
+
current_user.skip_onboarding_step!(@current_step[:name])
|
|
136
|
+
if current_user.onboarding_completed?
|
|
137
|
+
redirect_to_after_completion
|
|
138
|
+
else
|
|
139
|
+
redirect_to onboarding_path
|
|
140
|
+
end
|
|
141
|
+
else
|
|
142
|
+
respond_to do |format|
|
|
143
|
+
format.html do
|
|
144
|
+
redirect_to onboarding_path, alert: "This step cannot be skipped."
|
|
145
|
+
end
|
|
146
|
+
format.turbo_stream do
|
|
147
|
+
render turbo_stream: turbo_stream.replace(
|
|
148
|
+
"flash-messages",
|
|
149
|
+
partial: "rails_onboarding/shared/flash",
|
|
150
|
+
locals: { alert: "This step cannot be skipped." }
|
|
151
|
+
), status: :unprocessable_entity
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def back
|
|
158
|
+
unless current_user.can_go_back?
|
|
159
|
+
respond_to do |format|
|
|
160
|
+
format.html { redirect_to onboarding_path, alert: "Cannot go back from the first step." }
|
|
161
|
+
format.turbo_stream do
|
|
162
|
+
render turbo_stream: turbo_stream.replace(
|
|
163
|
+
"flash-messages",
|
|
164
|
+
partial: "rails_onboarding/shared/flash",
|
|
165
|
+
locals: { alert: "Cannot go back from the first step." }
|
|
166
|
+
), status: :unprocessable_entity
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
return
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
session_id = RailsOnboarding::SessionManager.session_id(current_user, session)
|
|
173
|
+
current_user.go_back!(session_id: session_id)
|
|
174
|
+
|
|
175
|
+
redirect_to onboarding_path
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def restart
|
|
179
|
+
session_id = RailsOnboarding::SessionManager.session_id(current_user, session)
|
|
180
|
+
current_user.restart_onboarding!(session_id: session_id)
|
|
181
|
+
RailsOnboarding::SessionManager.clear_session(current_user, session)
|
|
182
|
+
|
|
183
|
+
redirect_to onboarding_path, notice: "Onboarding has been restarted."
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def authenticate_user!
|
|
189
|
+
# This should be overridden by the host app
|
|
190
|
+
# or use the host app's authentication
|
|
191
|
+
unless respond_to?(:current_user, true) && current_user.present?
|
|
192
|
+
redirect_to main_app.root_path
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def check_onboarding_status
|
|
197
|
+
if current_user.onboarding_completed?
|
|
198
|
+
redirect_to_after_completion
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def set_step
|
|
203
|
+
if current_user
|
|
204
|
+
@current_step = current_user.current_onboarding_step
|
|
205
|
+
@next_step = current_user.next_onboarding_step
|
|
206
|
+
@progress = current_user.onboarding_progress
|
|
207
|
+
@total_steps = RailsOnboarding.configuration.total_steps
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
@progress ||= 0
|
|
211
|
+
@total_steps ||= 4
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# True the first time this user reaches the given step. Existing
|
|
215
|
+
# step_started events are matched in Ruby because `properties` is a
|
|
216
|
+
# JSON-serialized text column (not portably queryable via SQL). The set of
|
|
217
|
+
# a user's step_started events is bounded by the number of steps, so this
|
|
218
|
+
# stays cheap.
|
|
219
|
+
def first_entry_to_step?(step_name)
|
|
220
|
+
return true unless RailsOnboarding::AnalyticsEvent.table_exists?
|
|
221
|
+
|
|
222
|
+
RailsOnboarding::AnalyticsEvent
|
|
223
|
+
.where(user: current_user, event_type: RailsOnboarding::AnalyticsEvent::ONBOARDING_STEP_STARTED)
|
|
224
|
+
.none? { |event| event.properties.to_h["step_name"].to_s == step_name.to_s }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Advance past every consecutive step whose :complete_if predicate is
|
|
228
|
+
# already satisfied. A loop (not a single check) because a returning user
|
|
229
|
+
# may have satisfied several steps at once. Redirects to the completion
|
|
230
|
+
# path when advancing finishes the flow, so callers must check performed?.
|
|
231
|
+
def advance_completed_steps
|
|
232
|
+
awarded_milestones = []
|
|
233
|
+
RailsOnboarding.configuration.total_steps.times do
|
|
234
|
+
break unless @current_step && step_criteria_met?(@current_step)
|
|
235
|
+
|
|
236
|
+
if defined?(RailsOnboarding::MilestoneService)
|
|
237
|
+
milestones = RailsOnboarding::MilestoneService.check_onboarding_step_milestones(
|
|
238
|
+
current_user,
|
|
239
|
+
@current_step[:name]
|
|
240
|
+
) || []
|
|
241
|
+
awarded_milestones.concat(milestones)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
current_user.complete_onboarding_step!(@current_step[:name])
|
|
245
|
+
|
|
246
|
+
if current_user.onboarding_completed?
|
|
247
|
+
if defined?(RailsOnboarding::MilestoneService)
|
|
248
|
+
completion_milestones = RailsOnboarding::MilestoneService.check_onboarding_completion_milestones(current_user) || []
|
|
249
|
+
awarded_milestones.concat(completion_milestones)
|
|
250
|
+
end
|
|
251
|
+
redirect_to_after_completion(awarded_milestones)
|
|
252
|
+
return
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
@current_step = current_user.current_onboarding_step
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
set_step
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# A buggy :complete_if must not brick onboarding - if it raises, treat the
|
|
262
|
+
# step as not yet complete instead of letting the shared StandardError
|
|
263
|
+
# handler redirect back to /onboarding (which would re-raise on arrival,
|
|
264
|
+
# redirecting again in an endless browser loop).
|
|
265
|
+
def step_criteria_met?(step)
|
|
266
|
+
return false unless step[:complete_if].is_a?(Proc)
|
|
267
|
+
|
|
268
|
+
step[:complete_if].call(current_user)
|
|
269
|
+
rescue StandardError => e
|
|
270
|
+
Rails.logger.error("RailsOnboarding: complete_if for step '#{step[:name]}' raised #{e.class} - #{e.message}")
|
|
271
|
+
false
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Redirect to the host-app page that owns the current step. Returns false
|
|
275
|
+
# (without redirecting) when the configured path can't be resolved, so
|
|
276
|
+
# show can fall back to rendering a gem template instead of stranding the
|
|
277
|
+
# user in a redirect loop via the shared StandardError handler.
|
|
278
|
+
def redirect_to_step_page
|
|
279
|
+
path = resolve_onboarding_step_path(@current_step[:path])
|
|
280
|
+
redirect_to path
|
|
281
|
+
true
|
|
282
|
+
rescue StandardError => e
|
|
283
|
+
Rails.logger.error(
|
|
284
|
+
"RailsOnboarding: could not resolve path #{@current_step[:path].inspect} " \
|
|
285
|
+
"for step '#{@current_step[:name]}': #{e.class} - #{e.message}"
|
|
286
|
+
)
|
|
287
|
+
false
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def redirect_to_after_completion(awarded_milestones = [])
|
|
291
|
+
path = RailsOnboarding.configuration.redirect_after_completion
|
|
292
|
+
notice = "Welcome! You've completed the onboarding."
|
|
293
|
+
|
|
294
|
+
if awarded_milestones.any?
|
|
295
|
+
milestone_text = awarded_milestones.map { |m| "#{m[:icon]} #{m[:title]}" }.join(", ")
|
|
296
|
+
notice += " Milestones achieved: #{milestone_text}"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
redirect_to main_app.send(path), notice: notice
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def redirect_to_after_skip
|
|
303
|
+
path = RailsOnboarding.configuration.redirect_after_skip
|
|
304
|
+
redirect_to main_app.send(path), notice: "You can explore features at your own pace."
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def process_step_data(step_name, data)
|
|
308
|
+
# Sanitize input data before processing
|
|
309
|
+
sanitized_data = sanitize_step_data(data)
|
|
310
|
+
|
|
311
|
+
# Override in host app for custom processing
|
|
312
|
+
# Example:
|
|
313
|
+
# case step_name
|
|
314
|
+
# when :profile
|
|
315
|
+
# current_user.update(sanitized_data.permit(:timezone, :notifications_enabled))
|
|
316
|
+
# end
|
|
317
|
+
|
|
318
|
+
# Log sanitized data for security auditing
|
|
319
|
+
Rails.logger.info("Processing step data for #{step_name}: #{sanitized_data.inspect}")
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def sanitize_step_data(data)
|
|
323
|
+
return {} unless data.is_a?(ActionController::Parameters) || data.is_a?(Hash)
|
|
324
|
+
|
|
325
|
+
# Convert to ActionController::Parameters if needed
|
|
326
|
+
params_data = data.is_a?(ActionController::Parameters) ? data : ActionController::Parameters.new(data)
|
|
327
|
+
|
|
328
|
+
# Remove any potentially dangerous keys
|
|
329
|
+
dangerous_keys = %w[authenticity_token _method controller action]
|
|
330
|
+
params_data.except(*dangerous_keys)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def template_exists?(name)
|
|
334
|
+
lookup_context.exists?(name, [ "rails_onboarding/onboarding" ], false)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Error handling methods
|
|
338
|
+
|
|
339
|
+
def handle_validation_error(exception)
|
|
340
|
+
Rails.logger.error("Validation error in onboarding: #{exception.message}")
|
|
341
|
+
Rails.logger.error(exception.backtrace.join("\n")) if Rails.env.development?
|
|
342
|
+
|
|
343
|
+
error_message = "Unable to save changes: #{exception.record.errors.full_messages.join(', ')}"
|
|
344
|
+
|
|
345
|
+
respond_to do |format|
|
|
346
|
+
format.html do
|
|
347
|
+
redirect_to onboarding_path, alert: error_message
|
|
348
|
+
end
|
|
349
|
+
format.turbo_stream do
|
|
350
|
+
render turbo_stream: turbo_stream.replace(
|
|
351
|
+
"flash-messages",
|
|
352
|
+
partial: "rails_onboarding/shared/flash",
|
|
353
|
+
locals: { alert: error_message }
|
|
354
|
+
), status: :unprocessable_entity
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def handle_not_found(exception)
|
|
360
|
+
Rails.logger.error("Record not found in onboarding: #{exception.message}")
|
|
361
|
+
|
|
362
|
+
respond_to do |format|
|
|
363
|
+
format.html do
|
|
364
|
+
redirect_to main_app.root_path, alert: "Resource not found. Please try again."
|
|
365
|
+
end
|
|
366
|
+
format.turbo_stream do
|
|
367
|
+
render turbo_stream: turbo_stream.replace(
|
|
368
|
+
"flash-messages",
|
|
369
|
+
partial: "rails_onboarding/shared/flash",
|
|
370
|
+
locals: { alert: "Resource not found. Please try again." }
|
|
371
|
+
), status: :not_found
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def handle_standard_error(exception)
|
|
377
|
+
Rails.logger.error("Error in onboarding controller: #{exception.class} - #{exception.message}")
|
|
378
|
+
Rails.logger.error(exception.backtrace.join("\n"))
|
|
379
|
+
|
|
380
|
+
error_message = if Rails.env.production?
|
|
381
|
+
"An unexpected error occurred. Please try again or contact support."
|
|
382
|
+
else
|
|
383
|
+
"Error: #{exception.message}"
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Simple redirect for all error cases
|
|
387
|
+
redirect_to onboarding_path, alert: error_message
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def handle_configuration_error(message)
|
|
391
|
+
Rails.logger.error("Configuration error: #{message}")
|
|
392
|
+
|
|
393
|
+
error_message = "Onboarding configuration error. Please contact support."
|
|
394
|
+
|
|
395
|
+
respond_to do |format|
|
|
396
|
+
format.html do
|
|
397
|
+
redirect_to main_app.root_path, alert: error_message
|
|
398
|
+
end
|
|
399
|
+
format.turbo_stream do
|
|
400
|
+
render turbo_stream: turbo_stream.replace(
|
|
401
|
+
"flash-messages",
|
|
402
|
+
partial: "rails_onboarding/shared/flash",
|
|
403
|
+
locals: { alert: error_message }
|
|
404
|
+
), status: :internal_server_error
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsOnboarding
|
|
4
|
+
# Controller for managing progressive feature disclosure
|
|
5
|
+
# Handles revealing and tracking progressive features
|
|
6
|
+
class ProgressiveFeaturesController < ApplicationController
|
|
7
|
+
include RailsOnboarding::RateLimitable
|
|
8
|
+
|
|
9
|
+
before_action :authenticate_user!
|
|
10
|
+
before_action :set_feature, only: [ :reveal, :dismiss ]
|
|
11
|
+
|
|
12
|
+
# GET /progressive_features
|
|
13
|
+
# List all progressive features and their status
|
|
14
|
+
def index
|
|
15
|
+
@all_features = RailsOnboarding.configuration.progressive_features || []
|
|
16
|
+
@revealed_features = current_user.all_revealed_features
|
|
17
|
+
@ready_features = current_user.features_ready_to_reveal
|
|
18
|
+
|
|
19
|
+
respond_to do |format|
|
|
20
|
+
format.html
|
|
21
|
+
format.json do
|
|
22
|
+
render json: {
|
|
23
|
+
all_features: @all_features,
|
|
24
|
+
revealed: @revealed_features,
|
|
25
|
+
ready_to_reveal: @ready_features.map { |f| f[:key] }
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# GET /progressive_features/ready
|
|
32
|
+
# Get features that are ready to be revealed
|
|
33
|
+
def ready
|
|
34
|
+
@ready_features = current_user.features_ready_to_reveal
|
|
35
|
+
|
|
36
|
+
respond_to do |format|
|
|
37
|
+
format.json { render json: @ready_features }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# POST /progressive_features/:feature_key/reveal
|
|
42
|
+
# Manually reveal a feature
|
|
43
|
+
def reveal
|
|
44
|
+
if current_user.reveal_feature(@feature[:key], source: :manual, revealed_by: current_user.id)
|
|
45
|
+
respond_to do |format|
|
|
46
|
+
format.html do
|
|
47
|
+
flash[:success] = "Feature '#{@feature[:title]}' has been revealed!"
|
|
48
|
+
redirect_back(fallback_location: root_path)
|
|
49
|
+
end
|
|
50
|
+
format.json { render json: { success: true, feature: @feature } }
|
|
51
|
+
end
|
|
52
|
+
else
|
|
53
|
+
respond_to do |format|
|
|
54
|
+
format.html do
|
|
55
|
+
flash[:error] = "Failed to reveal feature"
|
|
56
|
+
redirect_back(fallback_location: root_path)
|
|
57
|
+
end
|
|
58
|
+
format.json { render json: { success: false }, status: :unprocessable_entity }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# POST /progressive_features/:feature_key/dismiss
|
|
64
|
+
# Dismiss a feature notification without revealing it
|
|
65
|
+
def dismiss
|
|
66
|
+
# Track dismissal in analytics
|
|
67
|
+
if current_user.respond_to?(:track_analytics_event)
|
|
68
|
+
current_user.track_analytics_event(
|
|
69
|
+
"feature_dismissed",
|
|
70
|
+
feature_key: @feature[:key].to_s
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
respond_to do |format|
|
|
75
|
+
format.json { render json: { success: true } }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# POST /progressive_features/reveal_all_ready
|
|
80
|
+
# Reveal all features that are currently ready
|
|
81
|
+
def reveal_all_ready
|
|
82
|
+
newly_revealed = current_user.reveal_ready_features!
|
|
83
|
+
|
|
84
|
+
respond_to do |format|
|
|
85
|
+
format.html do
|
|
86
|
+
flash[:success] = "Revealed #{newly_revealed.size} new features!"
|
|
87
|
+
redirect_back(fallback_location: root_path)
|
|
88
|
+
end
|
|
89
|
+
format.json { render json: { success: true, revealed: newly_revealed } }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# GET /progressive_features/status
|
|
94
|
+
# Get the current status of progressive disclosure for the user
|
|
95
|
+
def status
|
|
96
|
+
render json: {
|
|
97
|
+
enabled: RailsOnboarding.configuration.progressive_disclosure_enabled,
|
|
98
|
+
total_features: RailsOnboarding.configuration.progressive_features&.size || 0,
|
|
99
|
+
revealed_count: current_user.revealed_features_count,
|
|
100
|
+
pending_count: current_user.pending_features_count,
|
|
101
|
+
ready_to_reveal: current_user.features_ready_to_reveal.map { |f| f[:key] }
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def set_feature
|
|
108
|
+
feature_key = params[:feature_key] || params[:id]
|
|
109
|
+
@feature = (RailsOnboarding.configuration.progressive_features || [])
|
|
110
|
+
.find { |f| f[:key].to_s == feature_key.to_s }
|
|
111
|
+
|
|
112
|
+
unless @feature
|
|
113
|
+
respond_to do |format|
|
|
114
|
+
format.html do
|
|
115
|
+
flash[:error] = "Feature not found"
|
|
116
|
+
redirect_to root_path
|
|
117
|
+
end
|
|
118
|
+
format.json { render json: { error: "Feature not found" }, status: :not_found }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def authenticate_user!
|
|
124
|
+
# This should be implemented by the host application
|
|
125
|
+
# or overridden in the host's ApplicationController
|
|
126
|
+
return if defined?(current_user) && current_user
|
|
127
|
+
|
|
128
|
+
respond_to do |format|
|
|
129
|
+
format.html { redirect_to main_app.root_path, alert: "Please sign in" }
|
|
130
|
+
format.json { render json: { error: "Unauthorized" }, status: :unauthorized }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsOnboarding
|
|
4
|
+
# Controller for managing onboarding templates
|
|
5
|
+
# Provides pre-built flows for common use cases
|
|
6
|
+
class TemplatesController < ApplicationController
|
|
7
|
+
include RailsOnboarding::AdminAuthorization
|
|
8
|
+
|
|
9
|
+
# apply/create_custom mutate the process-wide RailsOnboarding
|
|
10
|
+
# configuration (the onboarding flow every user sees), so this whole
|
|
11
|
+
# controller is admin-only - the same gate as the Admin dashboard.
|
|
12
|
+
before_action :authenticate_admin!
|
|
13
|
+
before_action :verify_admin_authorization!
|
|
14
|
+
|
|
15
|
+
rescue_from UnauthorizedError, with: :handle_admin_unauthorized
|
|
16
|
+
rescue_from NotImplementedError, with: :handle_admin_not_implemented
|
|
17
|
+
|
|
18
|
+
# GET /templates
|
|
19
|
+
# List all available onboarding templates
|
|
20
|
+
def index
|
|
21
|
+
@templates = RailsOnboarding.configuration.onboarding_templates
|
|
22
|
+
|
|
23
|
+
respond_to do |format|
|
|
24
|
+
format.html
|
|
25
|
+
format.json { render json: @templates }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# GET /templates/:template_key
|
|
30
|
+
# Show details for a specific template
|
|
31
|
+
def show
|
|
32
|
+
@template_key = params[:template_key] || params[:id]
|
|
33
|
+
@template = RailsOnboarding.configuration.template(@template_key)
|
|
34
|
+
|
|
35
|
+
unless @template
|
|
36
|
+
respond_to do |format|
|
|
37
|
+
format.html do
|
|
38
|
+
flash[:error] = "Template not found: #{@template_key}"
|
|
39
|
+
redirect_to templates_path
|
|
40
|
+
end
|
|
41
|
+
format.json { render json: { error: "Template not found" }, status: :not_found }
|
|
42
|
+
end
|
|
43
|
+
return
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
respond_to do |format|
|
|
47
|
+
format.html
|
|
48
|
+
format.json { render json: { template_key: @template_key, template: @template } }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# POST /templates/:template_key/apply
|
|
53
|
+
# Apply a template to the current configuration
|
|
54
|
+
def apply
|
|
55
|
+
template_key = params[:template_key] || params[:id]
|
|
56
|
+
template = RailsOnboarding.configuration.template(template_key)
|
|
57
|
+
|
|
58
|
+
unless template
|
|
59
|
+
respond_to do |format|
|
|
60
|
+
format.html do
|
|
61
|
+
flash[:error] = "Template not found: #{template_key}"
|
|
62
|
+
redirect_to templates_path
|
|
63
|
+
end
|
|
64
|
+
format.json { render json: { error: "Template not found" }, status: :not_found }
|
|
65
|
+
end
|
|
66
|
+
return
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if RailsOnboarding.configuration.apply_template(template_key)
|
|
70
|
+
respond_to do |format|
|
|
71
|
+
format.html do
|
|
72
|
+
flash[:success] = "Template '#{template[:name]}' has been applied successfully!"
|
|
73
|
+
redirect_to onboarding_path
|
|
74
|
+
end
|
|
75
|
+
format.json do
|
|
76
|
+
render json: {
|
|
77
|
+
success: true,
|
|
78
|
+
template_key: template_key,
|
|
79
|
+
steps: RailsOnboarding.configuration.steps
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
else
|
|
84
|
+
respond_to do |format|
|
|
85
|
+
format.html do
|
|
86
|
+
flash[:error] = "Failed to apply template"
|
|
87
|
+
redirect_to templates_path
|
|
88
|
+
end
|
|
89
|
+
format.json { render json: { success: false }, status: :unprocessable_entity }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# POST /templates/:template_key/preview
|
|
95
|
+
# Preview what a template would look like without applying it
|
|
96
|
+
def preview
|
|
97
|
+
template_key = params[:template_key] || params[:id]
|
|
98
|
+
template = RailsOnboarding.configuration.template(template_key)
|
|
99
|
+
|
|
100
|
+
unless template
|
|
101
|
+
respond_to do |format|
|
|
102
|
+
format.json { render json: { error: "Template not found" }, status: :not_found }
|
|
103
|
+
end
|
|
104
|
+
return
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Return template details without actually applying it
|
|
108
|
+
render json: {
|
|
109
|
+
template_key: template_key,
|
|
110
|
+
name: template[:name],
|
|
111
|
+
steps: template[:steps],
|
|
112
|
+
total_steps: template[:steps]&.size || 0,
|
|
113
|
+
description: template[:description],
|
|
114
|
+
suitable_for: template[:suitable_for]
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# GET /templates/compare
|
|
119
|
+
# Compare multiple templates side by side
|
|
120
|
+
def compare
|
|
121
|
+
template_keys = params[:templates]&.split(",") || []
|
|
122
|
+
@templates_to_compare = {}
|
|
123
|
+
|
|
124
|
+
template_keys.each do |key|
|
|
125
|
+
template = RailsOnboarding.configuration.template(key.to_sym)
|
|
126
|
+
@templates_to_compare[key.to_sym] = template if template
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
respond_to do |format|
|
|
130
|
+
format.html
|
|
131
|
+
format.json { render json: @templates_to_compare }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# POST /templates/custom
|
|
136
|
+
# Create a custom template based on current configuration
|
|
137
|
+
def create_custom
|
|
138
|
+
template_name = params[:template_name] || "Custom Template"
|
|
139
|
+
template_key = params[:template_key]&.to_sym || :custom
|
|
140
|
+
|
|
141
|
+
custom_template = {
|
|
142
|
+
name: template_name,
|
|
143
|
+
steps: RailsOnboarding.configuration.steps,
|
|
144
|
+
description: params[:description] || "Custom onboarding flow",
|
|
145
|
+
created_at: Time.current
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# In a real application, you'd save this to the database
|
|
149
|
+
# For now, we'll just return it as JSON
|
|
150
|
+
|
|
151
|
+
respond_to do |format|
|
|
152
|
+
format.json do
|
|
153
|
+
render json: {
|
|
154
|
+
success: true,
|
|
155
|
+
template_key: template_key,
|
|
156
|
+
template: custom_template
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|