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,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsOnboarding
|
|
4
|
+
module Admin
|
|
5
|
+
# Admin flows controller
|
|
6
|
+
# Visual editor for creating and managing onboarding flows
|
|
7
|
+
class FlowsController < BaseController
|
|
8
|
+
before_action :set_flow, only: [ :show, :edit, :update, :destroy, :duplicate, :activate, :preview ]
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
@pagy, @flows = paginate(flows_collection)
|
|
12
|
+
# Looked up independently of the page: the active flow is called out at the
|
|
13
|
+
# top of the screen whether or not it falls on the page being viewed.
|
|
14
|
+
@active_flow = RailsOnboarding::Flow.active.first
|
|
15
|
+
@templates = available_templates
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def show
|
|
19
|
+
@steps = @flow.steps || []
|
|
20
|
+
@flow_stats = calculate_flow_stats(@flow)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def new
|
|
24
|
+
@flow = RailsOnboarding::Flow.new(default_flow_structure)
|
|
25
|
+
@available_icons = available_icons
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create
|
|
29
|
+
@flow = RailsOnboarding::Flow.new(flow_params)
|
|
30
|
+
|
|
31
|
+
if @flow.save
|
|
32
|
+
flash[:notice] = "Flow '#{@flow.name}' created successfully"
|
|
33
|
+
redirect_to admin_flows_path
|
|
34
|
+
else
|
|
35
|
+
flash.now[:alert] = "Failed to create flow: #{@flow.errors.full_messages.join(', ')}"
|
|
36
|
+
@available_icons = available_icons
|
|
37
|
+
render :new
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def edit
|
|
42
|
+
@available_icons = available_icons
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def update
|
|
46
|
+
if @flow.update(flow_params)
|
|
47
|
+
flash[:notice] = "Flow updated successfully"
|
|
48
|
+
redirect_to admin_flow_path(@flow)
|
|
49
|
+
else
|
|
50
|
+
flash.now[:alert] = "Failed to update flow: #{@flow.errors.full_messages.join(', ')}"
|
|
51
|
+
@available_icons = available_icons
|
|
52
|
+
render :edit
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def destroy
|
|
57
|
+
if @flow.active?
|
|
58
|
+
flash[:alert] = "Failed to delete flow. Cannot delete active flow."
|
|
59
|
+
elsif @flow.destroy
|
|
60
|
+
flash[:notice] = "Flow deleted successfully"
|
|
61
|
+
else
|
|
62
|
+
flash[:alert] = "Failed to delete flow."
|
|
63
|
+
end
|
|
64
|
+
redirect_to admin_flows_path
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def duplicate
|
|
68
|
+
new_flow = @flow.dup
|
|
69
|
+
new_flow.name = "#{@flow.name} (Copy)"
|
|
70
|
+
new_flow.active = false
|
|
71
|
+
|
|
72
|
+
if new_flow.save
|
|
73
|
+
flash[:notice] = "Flow duplicated successfully"
|
|
74
|
+
redirect_to admin_flow_path(new_flow)
|
|
75
|
+
else
|
|
76
|
+
flash[:alert] = "Failed to duplicate flow"
|
|
77
|
+
redirect_to admin_flows_path
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def activate
|
|
82
|
+
RailsOnboarding::Flow.activate!(@flow)
|
|
83
|
+
RailsOnboarding.configuration.clear_cache!
|
|
84
|
+
flash[:notice] = "Flow '#{@flow.name}' is now active"
|
|
85
|
+
redirect_to admin_flows_path
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
logger.error "Error activating flow: #{e.message}"
|
|
88
|
+
flash[:alert] = "Failed to activate flow"
|
|
89
|
+
redirect_to admin_flows_path
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def preview
|
|
93
|
+
@steps = @flow.steps || []
|
|
94
|
+
render layout: "rails_onboarding/application"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def set_flow
|
|
100
|
+
@flow = RailsOnboarding::Flow.find_by(id: params[:id])
|
|
101
|
+
unless @flow
|
|
102
|
+
flash[:alert] = "Flow not found"
|
|
103
|
+
redirect_to admin_flows_path
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def flows_collection
|
|
108
|
+
RailsOnboarding::Flow.seed_default! if RailsOnboarding::Flow.none?
|
|
109
|
+
RailsOnboarding::Flow.order(created_at: :asc)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def default_flow_structure
|
|
113
|
+
{
|
|
114
|
+
name: "",
|
|
115
|
+
description: "",
|
|
116
|
+
steps: [
|
|
117
|
+
{ name: "welcome", title: "Welcome", icon: "👋", skippable: true, order: 0 }
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def flow_params
|
|
123
|
+
permitted = params.require(:flow).permit(
|
|
124
|
+
:name, :description,
|
|
125
|
+
steps: [ :name, :title, :icon, :description, :skippable, :order ]
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if permitted[:steps]
|
|
129
|
+
permitted[:steps] = permitted[:steps].map { |step| step.to_h.symbolize_keys }
|
|
130
|
+
.sort_by { |step| step[:order].to_i }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
permitted
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def calculate_flow_stats(flow)
|
|
137
|
+
return {} unless defined?(RailsOnboarding::AnalyticsEvent)
|
|
138
|
+
|
|
139
|
+
steps = flow.steps || []
|
|
140
|
+
stats = {}
|
|
141
|
+
|
|
142
|
+
steps.each do |step|
|
|
143
|
+
step_name = step[:name].to_s
|
|
144
|
+
stats[step_name] = {
|
|
145
|
+
started: RailsOnboarding::AnalyticsEvent.where(event_type: RailsOnboarding::AnalyticsEvent::ONBOARDING_STEP_STARTED)
|
|
146
|
+
.select { |e| e.properties.to_h["step_name"].to_s == step_name }.count,
|
|
147
|
+
completed: RailsOnboarding::AnalyticsEvent.where(event_type: RailsOnboarding::AnalyticsEvent::ONBOARDING_STEP_COMPLETED)
|
|
148
|
+
.select { |e| e.properties.to_h["step_name"].to_s == step_name }.count
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
stats
|
|
153
|
+
rescue StandardError => e
|
|
154
|
+
logger.error "Error calculating flow stats: #{e.message}"
|
|
155
|
+
{}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def available_templates
|
|
159
|
+
return [] unless defined?(RailsOnboarding::Templates)
|
|
160
|
+
|
|
161
|
+
[
|
|
162
|
+
{ id: "saas", name: "SaaS Application", description: "Standard SaaS onboarding flow" },
|
|
163
|
+
{ id: "ecommerce", name: "E-commerce", description: "Onboarding for online stores" },
|
|
164
|
+
{ id: "marketplace", name: "Marketplace", description: "Two-sided marketplace flow" },
|
|
165
|
+
{ id: "community", name: "Community", description: "Social/community platform" },
|
|
166
|
+
{ id: "education", name: "Education", description: "Learning platform flow" }
|
|
167
|
+
]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def available_icons
|
|
171
|
+
[ "👋", "🎉", "👤", "📝", "🚀", "🔍", "⚙️", "💡", "📊", "🎯", "✨", "🏆", "📱", "💬", "🔔", "📧", "🎨", "🔒" ]
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsOnboarding
|
|
4
|
+
module Admin
|
|
5
|
+
# Admin users controller
|
|
6
|
+
# Manage and view user onboarding progress
|
|
7
|
+
class UsersController < BaseController
|
|
8
|
+
MYSQL_ADAPTERS = %w[mysql2 trilogy mysql].freeze
|
|
9
|
+
|
|
10
|
+
# The cast target for turning an integer id into something LIKE can match
|
|
11
|
+
# is not portable. MySQL rejects `CAST(x AS TEXT)` outright as a syntax
|
|
12
|
+
# error, and PostgreSQL accepts `CAST(x AS CHAR)` but reads it as
|
|
13
|
+
# character(1), silently truncating the id to its first digit. SQLite
|
|
14
|
+
# takes either. Defined on the class rather than as an instance method so
|
|
15
|
+
# Rails doesn't expose it as a controller action.
|
|
16
|
+
def self.id_cast_type_for(adapter_name)
|
|
17
|
+
MYSQL_ADAPTERS.include?(adapter_name.to_s.downcase) ? "CHAR" : "TEXT"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def index
|
|
21
|
+
@pagy, @users = paginate(filtered_users)
|
|
22
|
+
@stats = calculate_stats
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def show
|
|
26
|
+
@user = user_class.find(params[:id])
|
|
27
|
+
load_user_analytics
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def reset_onboarding
|
|
31
|
+
@user = user_class.find(params[:id])
|
|
32
|
+
|
|
33
|
+
if @user.reset_onboarding!
|
|
34
|
+
flash[:notice] = "Onboarding reset for user #{@user.id}"
|
|
35
|
+
redirect_to admin_user_path(@user)
|
|
36
|
+
else
|
|
37
|
+
flash[:alert] = "Failed to reset onboarding"
|
|
38
|
+
redirect_to admin_user_path(@user)
|
|
39
|
+
end
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
flash[:alert] = "Error resetting onboarding: #{e.message}"
|
|
42
|
+
redirect_to admin_users_path
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def complete_onboarding
|
|
46
|
+
@user = user_class.find(params[:id])
|
|
47
|
+
|
|
48
|
+
if @user.complete_onboarding!
|
|
49
|
+
flash[:notice] = "Onboarding completed for user #{@user.id}"
|
|
50
|
+
redirect_to admin_user_path(@user)
|
|
51
|
+
else
|
|
52
|
+
flash[:alert] = "Failed to complete onboarding"
|
|
53
|
+
redirect_to admin_user_path(@user)
|
|
54
|
+
end
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
flash[:alert] = "Error completing onboarding: #{e.message}"
|
|
57
|
+
redirect_to admin_users_path
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def restart_onboarding
|
|
61
|
+
@user = user_class.find(params[:id])
|
|
62
|
+
|
|
63
|
+
@user.restart_onboarding!
|
|
64
|
+
flash[:notice] = "Onboarding restarted for user #{@user.id}"
|
|
65
|
+
redirect_to admin_user_path(@user)
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
flash[:alert] = "Error restarting onboarding: #{e.message}"
|
|
68
|
+
redirect_to admin_users_path
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def bulk_action
|
|
72
|
+
action = params[:bulk_action]
|
|
73
|
+
user_ids = params[:user_ids] || []
|
|
74
|
+
|
|
75
|
+
case action
|
|
76
|
+
when "reset_onboarding"
|
|
77
|
+
bulk_reset_onboarding(user_ids)
|
|
78
|
+
when "complete_onboarding"
|
|
79
|
+
bulk_complete_onboarding(user_ids)
|
|
80
|
+
else
|
|
81
|
+
flash[:alert] = "Invalid bulk action"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
redirect_to admin_users_path
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Exports every user matching the current filters, not just the page being
|
|
88
|
+
# viewed - the button sits under the filter form, so "export what I'm
|
|
89
|
+
# looking at" is the expectation. Deliberately unpaginated.
|
|
90
|
+
# CSV is the only representation, so this deliberately doesn't respond_to:
|
|
91
|
+
# a bare /admin/users/export would otherwise raise UnknownFormat, which the
|
|
92
|
+
# admin error handler turns into a dashboard redirect with an alert.
|
|
93
|
+
def export
|
|
94
|
+
send_data generate_users_csv,
|
|
95
|
+
filename: "onboarding_users_#{Date.current}.csv",
|
|
96
|
+
type: "text/csv"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def generate_users_csv
|
|
102
|
+
require "csv"
|
|
103
|
+
|
|
104
|
+
has_email = user_class.column_names.include?("email")
|
|
105
|
+
|
|
106
|
+
CSV.generate do |csv|
|
|
107
|
+
csv << csv_headers(has_email)
|
|
108
|
+
# Iterated in the admin's chosen sort order, so no find_each here: it
|
|
109
|
+
# would override the ORDER BY with a primary-key scan and silently
|
|
110
|
+
# discard the sort the export was requested under.
|
|
111
|
+
filtered_users.each { |user| csv << csv_row(user, has_email) }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def csv_headers(has_email)
|
|
116
|
+
headers = [ "ID" ]
|
|
117
|
+
headers << "Email" if has_email
|
|
118
|
+
headers + [ "Status", "Current Step", "Progress (%)", "Completed At", "Created At", "Last Activity" ]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def csv_row(user, has_email)
|
|
122
|
+
row = [ user.id ]
|
|
123
|
+
row << user.email if has_email
|
|
124
|
+
row + [
|
|
125
|
+
onboarding_status_label(user),
|
|
126
|
+
user.onboarding_current_step,
|
|
127
|
+
user.respond_to?(:onboarding_progress_percentage) ? user.onboarding_progress_percentage : nil,
|
|
128
|
+
user.onboarding_completed_at&.iso8601,
|
|
129
|
+
user.created_at&.iso8601,
|
|
130
|
+
user.updated_at&.iso8601
|
|
131
|
+
]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Mirrors the badge shown in the index table, including its precedence:
|
|
135
|
+
# completed wins over skipped, which wins over in-progress.
|
|
136
|
+
def onboarding_status_label(user)
|
|
137
|
+
if user.onboarding_completed
|
|
138
|
+
"Completed"
|
|
139
|
+
elsif user.onboarding_skipped
|
|
140
|
+
"Skipped"
|
|
141
|
+
elsif user.onboarding_current_step
|
|
142
|
+
"In Progress"
|
|
143
|
+
else
|
|
144
|
+
"Not Started"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The search box covers both email and ID. Each half contributes a scope
|
|
149
|
+
# only when it could actually match; if neither can, the result is none
|
|
150
|
+
# rather than an empty WHERE that would return every user.
|
|
151
|
+
#
|
|
152
|
+
# A host app can replace the whole thing via
|
|
153
|
+
# `config.admin_user_search` - the built-in search is SQL, so it can't
|
|
154
|
+
# see through an encrypted email column, and only the host app knows
|
|
155
|
+
# what it's willing to trade to search one.
|
|
156
|
+
def apply_search(users, term)
|
|
157
|
+
custom = RailsOnboarding.configuration.admin_user_search
|
|
158
|
+
return custom.call(users, term) if custom.respond_to?(:call)
|
|
159
|
+
|
|
160
|
+
scopes = [ email_search_scope(users, term), id_search_scope(users, term) ].compact
|
|
161
|
+
return users.none if scopes.empty?
|
|
162
|
+
|
|
163
|
+
scopes.reduce { |combined, scope| combined.or(scope) }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Rails' `encrypts` stores ciphertext in the column, so a LIKE against a
|
|
167
|
+
# substring of the plaintext can't match - and worse, it happily matches
|
|
168
|
+
# ciphertext that happens to contain the term, so an app with an
|
|
169
|
+
# encrypted email column sees confident nonsense rather than nothing.
|
|
170
|
+
# Deterministic encryption still supports exact equality (same plaintext
|
|
171
|
+
# always encrypts to the same ciphertext), so fall back to that. With
|
|
172
|
+
# non-deterministic encryption there is nothing to search on at all.
|
|
173
|
+
def email_search_scope(users, term)
|
|
174
|
+
return nil unless user_class.column_names.include?("email")
|
|
175
|
+
|
|
176
|
+
case email_encryption
|
|
177
|
+
when :none
|
|
178
|
+
users.where("#{user_class.table_name}.email LIKE ?", "%#{term}%")
|
|
179
|
+
when :deterministic
|
|
180
|
+
users.where(email: term)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def email_encryption
|
|
185
|
+
return :none unless user_class.respond_to?(:encrypted_attributes)
|
|
186
|
+
return :none unless user_class.encrypted_attributes&.include?(:email)
|
|
187
|
+
|
|
188
|
+
type = user_class.type_for_attribute("email")
|
|
189
|
+
type.respond_to?(:deterministic?) && type.deterministic? ? :deterministic : :non_deterministic
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# An id renders as digits, so a non-numeric term can never be a substring
|
|
193
|
+
# of one. Dropping the clause in that case keeps the common email search
|
|
194
|
+
# off a full-table cast scan rather than merely being tidy.
|
|
195
|
+
def id_search_scope(users, term)
|
|
196
|
+
return nil unless term.match?(/\A\d+\z/)
|
|
197
|
+
|
|
198
|
+
cast_type = self.class.id_cast_type_for(user_class.connection.adapter_name)
|
|
199
|
+
users.where("CAST(#{user_class.table_name}.id AS #{cast_type}) LIKE ?", "%#{term}%")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def filtered_users
|
|
203
|
+
users = user_class.all
|
|
204
|
+
|
|
205
|
+
# Filter by onboarding status
|
|
206
|
+
if params[:status].present?
|
|
207
|
+
users = case params[:status]
|
|
208
|
+
when "completed"
|
|
209
|
+
users.where(onboarding_completed: true)
|
|
210
|
+
when "in_progress"
|
|
211
|
+
users.where(onboarding_completed: false).where.not(onboarding_current_step: nil)
|
|
212
|
+
when "not_started"
|
|
213
|
+
users.where(onboarding_current_step: nil, onboarding_completed: false)
|
|
214
|
+
when "skipped"
|
|
215
|
+
users.where(onboarding_skipped: true)
|
|
216
|
+
else
|
|
217
|
+
users
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Filter by current step
|
|
222
|
+
if params[:step].present?
|
|
223
|
+
users = users.where(onboarding_current_step: params[:step])
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Search by email or ID
|
|
227
|
+
users = apply_search(users, params[:search]) if params[:search].present?
|
|
228
|
+
|
|
229
|
+
# Sort - sanitize column and direction to prevent SQL injection
|
|
230
|
+
allowed_sort_columns = %w[email created_at updated_at onboarding_current_step onboarding_completed_at].freeze
|
|
231
|
+
allowed_directions = %w[asc desc].freeze
|
|
232
|
+
|
|
233
|
+
sort_column = params[:sort] || "created_at"
|
|
234
|
+
sort_direction = params[:direction] || "desc"
|
|
235
|
+
|
|
236
|
+
# Sanitize inputs
|
|
237
|
+
sort_column = allowed_sort_columns.include?(sort_column) ? sort_column : "created_at"
|
|
238
|
+
sort_direction = allowed_directions.include?(sort_direction.downcase) ? sort_direction.downcase : "desc"
|
|
239
|
+
|
|
240
|
+
users = users.order("#{user_class.table_name}.#{sort_column} #{sort_direction}")
|
|
241
|
+
|
|
242
|
+
users
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def calculate_stats
|
|
246
|
+
{
|
|
247
|
+
total: user_class.count,
|
|
248
|
+
completed: user_class.where(onboarding_completed: true).count,
|
|
249
|
+
in_progress: user_class.where(onboarding_completed: false).where.not(onboarding_current_step: nil).count,
|
|
250
|
+
not_started: user_class.where(onboarding_current_step: nil, onboarding_completed: false).count,
|
|
251
|
+
skipped: user_class.where(onboarding_skipped: true).count
|
|
252
|
+
}
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def load_user_analytics
|
|
256
|
+
return unless defined?(RailsOnboarding::AnalyticsEvent)
|
|
257
|
+
|
|
258
|
+
@user_events = RailsOnboarding::AnalyticsEvent
|
|
259
|
+
.where(user_id: @user.id)
|
|
260
|
+
.order(created_at: :desc)
|
|
261
|
+
.limit(50)
|
|
262
|
+
|
|
263
|
+
@user_milestones = if defined?(RailsOnboarding::MilestoneAchievement)
|
|
264
|
+
RailsOnboarding::MilestoneAchievement
|
|
265
|
+
.where(user_id: @user.id)
|
|
266
|
+
.includes(:milestone)
|
|
267
|
+
.order(achieved_at: :desc)
|
|
268
|
+
else
|
|
269
|
+
[]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
@user_timeline = build_user_timeline
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def build_user_timeline
|
|
276
|
+
timeline = []
|
|
277
|
+
|
|
278
|
+
if @user.created_at
|
|
279
|
+
timeline << {
|
|
280
|
+
type: "user_created",
|
|
281
|
+
timestamp: @user.created_at,
|
|
282
|
+
description: "User account created"
|
|
283
|
+
}
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
if defined?(RailsOnboarding::AnalyticsEvent)
|
|
287
|
+
@user_events.each do |event|
|
|
288
|
+
timeline << {
|
|
289
|
+
type: event.event_type,
|
|
290
|
+
timestamp: event.created_at,
|
|
291
|
+
description: format_event_description(event),
|
|
292
|
+
metadata: event.properties.to_h
|
|
293
|
+
}
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
if @user_milestones.any?
|
|
298
|
+
@user_milestones.each do |achievement|
|
|
299
|
+
timeline << {
|
|
300
|
+
type: "milestone_achieved",
|
|
301
|
+
timestamp: achievement.achieved_at,
|
|
302
|
+
description: "Achieved milestone: #{achievement.milestone.title}",
|
|
303
|
+
metadata: { points: achievement.milestone.points }
|
|
304
|
+
}
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# A nil timestamp would blow up the sort, and the timeline renders every
|
|
309
|
+
# entry as a date - so drop entries we can't place in time at all.
|
|
310
|
+
timeline.reject { |item| item[:timestamp].nil? }
|
|
311
|
+
.sort_by { |item| item[:timestamp] }
|
|
312
|
+
.reverse
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def format_event_description(event)
|
|
316
|
+
props = event.properties.to_h
|
|
317
|
+
case event.event_type
|
|
318
|
+
when RailsOnboarding::AnalyticsEvent::ONBOARDING_STARTED
|
|
319
|
+
"Started onboarding"
|
|
320
|
+
when RailsOnboarding::AnalyticsEvent::ONBOARDING_STEP_STARTED
|
|
321
|
+
"Started step: #{props['step_name']}"
|
|
322
|
+
when RailsOnboarding::AnalyticsEvent::ONBOARDING_STEP_COMPLETED
|
|
323
|
+
"Completed step: #{props['step_name']}"
|
|
324
|
+
when RailsOnboarding::AnalyticsEvent::ONBOARDING_STEP_SKIPPED
|
|
325
|
+
"Skipped step: #{props['step_name']}"
|
|
326
|
+
when RailsOnboarding::AnalyticsEvent::ONBOARDING_COMPLETED
|
|
327
|
+
"Completed onboarding"
|
|
328
|
+
when RailsOnboarding::AnalyticsEvent::ONBOARDING_SKIPPED
|
|
329
|
+
"Skipped onboarding"
|
|
330
|
+
when RailsOnboarding::AnalyticsEvent::TOOLTIP_SHOWN
|
|
331
|
+
"Tooltip shown: #{props['tooltip_feature']}"
|
|
332
|
+
when RailsOnboarding::AnalyticsEvent::TOOLTIP_DISMISSED
|
|
333
|
+
"Tooltip dismissed: #{props['tooltip_feature']}"
|
|
334
|
+
else
|
|
335
|
+
event.event_type.humanize
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def bulk_reset_onboarding(user_ids)
|
|
340
|
+
count = 0
|
|
341
|
+
user_ids.each do |user_id|
|
|
342
|
+
user = user_class.find_by(id: user_id)
|
|
343
|
+
count += 1 if user&.reset_onboarding!
|
|
344
|
+
end
|
|
345
|
+
flash[:notice] = "Reset onboarding for #{count} user(s)"
|
|
346
|
+
rescue StandardError => e
|
|
347
|
+
flash[:alert] = "Error in bulk reset: #{e.message}"
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def bulk_complete_onboarding(user_ids)
|
|
351
|
+
count = 0
|
|
352
|
+
user_ids.each do |user_id|
|
|
353
|
+
user = user_class.find_by(id: user_id)
|
|
354
|
+
count += 1 if user&.complete_onboarding!
|
|
355
|
+
end
|
|
356
|
+
flash[:notice] = "Completed onboarding for #{count} user(s)"
|
|
357
|
+
rescue StandardError => e
|
|
358
|
+
flash[:alert] = "Error in bulk complete: #{e.message}"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def user_class
|
|
362
|
+
@user_class ||= RailsOnboarding.configuration.user_class_name.constantize
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module RailsOnboarding
|
|
2
|
+
class ApplicationController < ApplicationController
|
|
3
|
+
# Inherits from the host app's ApplicationController
|
|
4
|
+
# This gives us access to:
|
|
5
|
+
# - current_user method
|
|
6
|
+
# - authentication filters
|
|
7
|
+
# - any other helpers defined in the host app
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
module RailsOnboarding
|
|
2
|
+
class MilestonesController < ApplicationController
|
|
3
|
+
include RailsOnboarding::RateLimitable
|
|
4
|
+
|
|
5
|
+
before_action :authenticate_user!
|
|
6
|
+
before_action :check_milestones_enabled
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
achieved_milestones = current_user.achieved_milestones.map do |key|
|
|
10
|
+
RailsOnboarding.configuration.milestone_by_key(key)
|
|
11
|
+
end.compact
|
|
12
|
+
|
|
13
|
+
available_milestones = current_user.milestones_available
|
|
14
|
+
total_points = current_user.total_milestone_points
|
|
15
|
+
|
|
16
|
+
respond_to do |format|
|
|
17
|
+
format.html do
|
|
18
|
+
@achieved_milestones = achieved_milestones
|
|
19
|
+
@available_milestones = available_milestones
|
|
20
|
+
@total_points = total_points
|
|
21
|
+
end
|
|
22
|
+
format.json do
|
|
23
|
+
render json: (achieved_milestones + available_milestones)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def show
|
|
29
|
+
milestone_key = params[:id]
|
|
30
|
+
@milestone = RailsOnboarding.configuration.milestone_by_key(milestone_key)
|
|
31
|
+
|
|
32
|
+
unless @milestone
|
|
33
|
+
redirect_to milestones_path, alert: "Milestone not found"
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@achieved = current_user.milestone_achieved?(milestone_key)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def achieve
|
|
41
|
+
milestone = MilestoneService.claim_if_eligible(current_user, params[:milestone_key])
|
|
42
|
+
|
|
43
|
+
if milestone
|
|
44
|
+
render json: {
|
|
45
|
+
success: true,
|
|
46
|
+
milestone: milestone,
|
|
47
|
+
total_points: current_user.total_milestone_points,
|
|
48
|
+
celebration: true
|
|
49
|
+
}
|
|
50
|
+
else
|
|
51
|
+
render json: {
|
|
52
|
+
success: false,
|
|
53
|
+
error: "Milestone could not be achieved"
|
|
54
|
+
}, status: :forbidden
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def recent
|
|
59
|
+
@recent_milestones = current_user.recent_milestones(limit: params[:limit]&.to_i || 5)
|
|
60
|
+
render json: @recent_milestones
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def progress
|
|
64
|
+
render json: {
|
|
65
|
+
points: current_user.milestone_points || 0,
|
|
66
|
+
achieved: current_user.milestones_achieved.map { |m| m["key"] || m[:key] }.compact
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def check
|
|
71
|
+
milestone_id = params[:id]
|
|
72
|
+
achieved = current_user.milestone_achieved?(milestone_id)
|
|
73
|
+
|
|
74
|
+
render json: { achieved: achieved }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def trigger
|
|
78
|
+
milestone_id = params[:milestone_id]
|
|
79
|
+
|
|
80
|
+
# Check if milestone is already achieved
|
|
81
|
+
if current_user.milestone_achieved?(milestone_id)
|
|
82
|
+
render json: {
|
|
83
|
+
success: false,
|
|
84
|
+
message: "Milestone already achieved"
|
|
85
|
+
}
|
|
86
|
+
return
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Award the milestone only if the user actually qualifies for it
|
|
90
|
+
milestone = MilestoneService.claim_if_eligible(current_user, milestone_id)
|
|
91
|
+
|
|
92
|
+
if milestone
|
|
93
|
+
render json: {
|
|
94
|
+
success: true,
|
|
95
|
+
celebration: true,
|
|
96
|
+
points_awarded: milestone[:points] || 0,
|
|
97
|
+
total_points: current_user.milestone_points || 0
|
|
98
|
+
}
|
|
99
|
+
else
|
|
100
|
+
render json: {
|
|
101
|
+
success: false,
|
|
102
|
+
error: "You are not eligible for this milestone yet"
|
|
103
|
+
}, status: :forbidden
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def available
|
|
108
|
+
available_milestones = current_user.milestones_available || []
|
|
109
|
+
render json: available_milestones
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def authenticate_user!
|
|
115
|
+
unless respond_to?(:current_user) && current_user
|
|
116
|
+
redirect_to main_app.root_path, alert: "Please log in to view milestones"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def check_milestones_enabled
|
|
121
|
+
unless RailsOnboarding.configuration.enable_milestones
|
|
122
|
+
redirect_to main_app.root_path, alert: "Milestones are not enabled"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|