@codyswann/lisa 1.36.0 → 1.38.0
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.
- package/all/copy-overwrite/CLAUDE.md +1 -0
- package/dist/core/config.d.ts +1 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +2 -0
- package/dist/core/config.js.map +1 -1
- package/dist/detection/detectors/rails.d.ts +15 -0
- package/dist/detection/detectors/rails.d.ts.map +1 -0
- package/dist/detection/detectors/rails.js +34 -0
- package/dist/detection/detectors/rails.js.map +1 -0
- package/dist/detection/index.d.ts.map +1 -1
- package/dist/detection/index.js +2 -0
- package/dist/detection/index.js.map +1 -1
- package/package.json +2 -1
- package/rails/copy-contents/Gemfile +3 -0
- package/rails/copy-overwrite/.claude/rules/lisa.md +44 -0
- package/rails/copy-overwrite/.claude/rules/rails-conventions.md +176 -0
- package/rails/copy-overwrite/.claude/skills/action-controller-best-practices/SKILL.md +374 -0
- package/rails/copy-overwrite/.claude/skills/action-view-best-practices/SKILL.md +335 -0
- package/rails/copy-overwrite/.claude/skills/active-record-model-best-practices/SKILL.md +166 -0
- package/rails/copy-overwrite/.claude/skills/plan-add-test-coverage/SKILL.md +45 -0
- package/rails/copy-overwrite/.claude/skills/plan-fix-linter-error/SKILL.md +45 -0
- package/rails/copy-overwrite/.claude/skills/plan-lower-code-complexity/SKILL.md +48 -0
- package/rails/copy-overwrite/.claude/skills/plan-reduce-max-lines/SKILL.md +46 -0
- package/rails/copy-overwrite/.claude/skills/plan-reduce-max-lines-per-function/SKILL.md +46 -0
- package/rails/copy-overwrite/.rubocop.yml +32 -0
- package/rails/copy-overwrite/.versionrc +48 -0
- package/rails/copy-overwrite/CLAUDE.md +56 -0
- package/rails/copy-overwrite/Gemfile.lisa +52 -0
- package/rails/copy-overwrite/config/initializers/version.rb +5 -0
- package/rails/copy-overwrite/lefthook.yml +20 -0
- package/rails/create-only/.github/workflows/ci.yml +11 -0
- package/rails/create-only/.github/workflows/quality.yml +96 -0
- package/rails/create-only/.github/workflows/release.yml +94 -0
- package/rails/create-only/.mise.toml +2 -0
- package/rails/create-only/.reek.yml +34 -0
- package/rails/create-only/.rspec +3 -0
- package/rails/create-only/.rubocop.local.yml +22 -0
- package/rails/create-only/.simplecov +20 -0
- package/rails/create-only/VERSION +1 -0
- package/rails/create-only/sonar-project.properties +15 -0
- package/rails/create-only/spec/rails_helper.rb +33 -0
- package/rails/create-only/spec/spec_helper.rb +21 -0
- package/rails/deletions.json +3 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: action-controller-best-practices
|
|
3
|
+
description: Build or Refactor large Rails controller files into clean, maintainable code. Use when a controller action exceeds ~10 lines, a controller has custom non-RESTful actions, or when the user asks to refactor, slim down, clean up, or organize a Rails controller. Applies patterns: service objects, query objects, form objects, controller concerns, presenters/decorators, and RESTful resource extraction.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Rails Controller Refactoring
|
|
7
|
+
|
|
8
|
+
Controllers should be thin traffic cops — they receive input via params, delegate to the appropriate object, and decide what to render or redirect. Each action should be roughly 5-10 lines. If an action is longer, logic needs to be extracted.
|
|
9
|
+
|
|
10
|
+
## Decision Framework
|
|
11
|
+
|
|
12
|
+
Read the controller and classify each block of code:
|
|
13
|
+
|
|
14
|
+
| Code type | Extract to | Location |
|
|
15
|
+
| ------------------------------------------------------- | ---------------------- | -------------------------------------- |
|
|
16
|
+
| Business logic, multi-step operations, side effects | Service object | `app/services/` |
|
|
17
|
+
| Complex queries, filtering, sorting, search | Query object | `app/queries/` |
|
|
18
|
+
| Context-specific param validation and persistence | Form object | `app/forms/` |
|
|
19
|
+
| Shared before_actions, auth, pagination, error handling | Controller concern | `app/controllers/concerns/` |
|
|
20
|
+
| Complex view data assembly, formatting, display logic | Presenter / Decorator | `app/presenters/` or `app/decorators/` |
|
|
21
|
+
| Non-RESTful custom actions on a different concept | New RESTful controller | `app/controllers/` |
|
|
22
|
+
| Simple CRUD, strong params, render/redirect | Keep on controller | — |
|
|
23
|
+
|
|
24
|
+
## Patterns
|
|
25
|
+
|
|
26
|
+
### Service Objects
|
|
27
|
+
|
|
28
|
+
Use for any business logic that goes beyond simple CRUD. A controller action should call one service at most.
|
|
29
|
+
|
|
30
|
+
Before:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
def create
|
|
34
|
+
@player = Player.new(player_params)
|
|
35
|
+
@player.team = Team.find(params[:team_id])
|
|
36
|
+
@player.contract_start = Time.current
|
|
37
|
+
@player.status = :active
|
|
38
|
+
|
|
39
|
+
if @player.save
|
|
40
|
+
PlayerMailer.welcome(@player).deliver_later
|
|
41
|
+
Analytics.track("player_signed", player_id: @player.id)
|
|
42
|
+
NotifyScoutsJob.perform_later(@player.team_id)
|
|
43
|
+
redirect_to @player, notice: "Player signed"
|
|
44
|
+
else
|
|
45
|
+
render :new, status: :unprocessable_entity
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
After:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# Controller
|
|
54
|
+
def create
|
|
55
|
+
result = Players::SignPlayer.new(player_params, team_id: params[:team_id]).call
|
|
56
|
+
|
|
57
|
+
if result.success?
|
|
58
|
+
redirect_to result.player, notice: "Player signed"
|
|
59
|
+
else
|
|
60
|
+
@player = result.player
|
|
61
|
+
render :new, status: :unprocessable_entity
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# app/services/players/sign_player.rb
|
|
66
|
+
module Players
|
|
67
|
+
class SignPlayer
|
|
68
|
+
def initialize(params, team_id:)
|
|
69
|
+
@params = params
|
|
70
|
+
@team_id = team_id
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def call
|
|
74
|
+
player = Player.new(@params)
|
|
75
|
+
player.team = Team.find(@team_id)
|
|
76
|
+
player.contract_start = Time.current
|
|
77
|
+
player.status = :active
|
|
78
|
+
|
|
79
|
+
if player.save
|
|
80
|
+
send_notifications(player)
|
|
81
|
+
Result.new(success: true, player: player)
|
|
82
|
+
else
|
|
83
|
+
Result.new(success: false, player: player)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def send_notifications(player)
|
|
90
|
+
PlayerMailer.welcome(player).deliver_later
|
|
91
|
+
Analytics.track("player_signed", player_id: player.id)
|
|
92
|
+
NotifyScoutsJob.perform_later(player.team_id)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
Result = Struct.new(:success, :player, keyword_init: true) do
|
|
96
|
+
alias_method :success?, :success
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Query Objects
|
|
103
|
+
|
|
104
|
+
Use when index actions have complex filtering, sorting, or search logic.
|
|
105
|
+
|
|
106
|
+
Before:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
def index
|
|
110
|
+
@players = Player.where(team_id: params[:team_id])
|
|
111
|
+
@players = @players.where(position: params[:position]) if params[:position].present?
|
|
112
|
+
@players = @players.where("age >= ?", params[:min_age]) if params[:min_age].present?
|
|
113
|
+
@players = @players.where(status: :active) unless params[:include_inactive]
|
|
114
|
+
@players = @players.joins(:stats).order("stats.war DESC")
|
|
115
|
+
@players = @players.page(params[:page]).per(25)
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
After:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
# Controller
|
|
123
|
+
def index
|
|
124
|
+
@players = Players::FilterQuery.new(params).call
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# app/queries/players/filter_query.rb
|
|
128
|
+
module Players
|
|
129
|
+
class FilterQuery
|
|
130
|
+
def initialize(params, relation: Player.all)
|
|
131
|
+
@params = params
|
|
132
|
+
@relation = relation
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def call
|
|
136
|
+
filter_by_team
|
|
137
|
+
filter_by_position
|
|
138
|
+
filter_by_age
|
|
139
|
+
filter_by_status
|
|
140
|
+
sort_and_paginate
|
|
141
|
+
@relation
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def filter_by_team
|
|
147
|
+
@relation = @relation.where(team_id: @params[:team_id]) if @params[:team_id].present?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def filter_by_position
|
|
151
|
+
@relation = @relation.where(position: @params[:position]) if @params[:position].present?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def filter_by_age
|
|
155
|
+
@relation = @relation.where("age >= ?", @params[:min_age]) if @params[:min_age].present?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def filter_by_status
|
|
159
|
+
@relation = @relation.where(status: :active) unless @params[:include_inactive]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def sort_and_paginate
|
|
163
|
+
@relation = @relation.joins(:stats).order("stats.war DESC").page(@params[:page]).per(25)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Controller Concerns
|
|
170
|
+
|
|
171
|
+
Use for shared behavior across multiple controllers: authentication, authorization, pagination, error handling, locale setting.
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
# app/controllers/concerns/paginatable.rb
|
|
175
|
+
module Paginatable
|
|
176
|
+
extend ActiveSupport::Concern
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
def page
|
|
181
|
+
params[:page] || 1
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def per_page
|
|
185
|
+
[params[:per_page].to_i, 100].min.nonzero? || 25
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# app/controllers/concerns/error_handleable.rb
|
|
190
|
+
module ErrorHandleable
|
|
191
|
+
extend ActiveSupport::Concern
|
|
192
|
+
|
|
193
|
+
included do
|
|
194
|
+
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
|
195
|
+
rescue_from ActionController::ParameterMissing, with: :bad_request
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
def not_found
|
|
201
|
+
render json: { error: "Not found" }, status: :not_found
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def bad_request(exception)
|
|
205
|
+
render json: { error: exception.message }, status: :bad_request
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Guidelines for controller concerns:
|
|
211
|
+
|
|
212
|
+
- Each concern should handle one cross-cutting aspect
|
|
213
|
+
- Don't use concerns to just split a large controller into files — that hides complexity
|
|
214
|
+
- Good candidates: auth, error handling, pagination, locale, current tenant scoping
|
|
215
|
+
- Bad candidates: domain-specific business logic
|
|
216
|
+
|
|
217
|
+
### Presenters / Decorators
|
|
218
|
+
|
|
219
|
+
Use when an action assembles complex data for the view that isn't a direct model attribute. Keeps view logic out of the controller.
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
# app/presenters/player_dashboard_presenter.rb
|
|
223
|
+
class PlayerDashboardPresenter
|
|
224
|
+
def initialize(player)
|
|
225
|
+
@player = player
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def career_stats
|
|
229
|
+
@career_stats ||= @player.stats.group(:season).sum(:war)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def contract_status_label
|
|
233
|
+
return "Free Agent" if @player.contract_end&.past?
|
|
234
|
+
"Under Contract (#{@player.contract_end&.year})"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def trade_value_rating
|
|
238
|
+
case @player.trade_value
|
|
239
|
+
when 90.. then "Elite"
|
|
240
|
+
when 70..89 then "High"
|
|
241
|
+
when 50..69 then "Average"
|
|
242
|
+
else "Low"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Controller
|
|
248
|
+
def show
|
|
249
|
+
@player = Player.find(params[:id])
|
|
250
|
+
@presenter = PlayerDashboardPresenter.new(@player)
|
|
251
|
+
end
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### RESTful Resource Extraction
|
|
255
|
+
|
|
256
|
+
When a controller has custom non-RESTful actions, it usually means there's a hidden resource. Extract it into its own controller with standard CRUD actions.
|
|
257
|
+
|
|
258
|
+
Before:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
class PlayersController < ApplicationController
|
|
262
|
+
def trade
|
|
263
|
+
# ...
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def release
|
|
267
|
+
# ...
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def promote_to_roster
|
|
271
|
+
# ...
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
After:
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
# config/routes.rb
|
|
280
|
+
resources :players do
|
|
281
|
+
resource :trade, only: [:new, :create], controller: "players/trades"
|
|
282
|
+
resource :release, only: [:create], controller: "players/releases"
|
|
283
|
+
resource :roster_promotion, only: [:create], controller: "players/roster_promotions"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# app/controllers/players/trades_controller.rb
|
|
287
|
+
module Players
|
|
288
|
+
class TradesController < ApplicationController
|
|
289
|
+
def new
|
|
290
|
+
@player = Player.find(params[:player_id])
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def create
|
|
294
|
+
result = Players::TradePlayer.new(Player.find(params[:player_id]), trade_params).call
|
|
295
|
+
# ...
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Signs you need a new controller:
|
|
302
|
+
|
|
303
|
+
- Action name is a verb that isn't a CRUD verb (trade, approve, archive, publish)
|
|
304
|
+
- Action operates on a different concept than the controller's resource
|
|
305
|
+
- Controller has more than the 7 RESTful actions
|
|
306
|
+
|
|
307
|
+
## Refactoring Process
|
|
308
|
+
|
|
309
|
+
1. **Read the entire controller** and identify every action, before_action, and private method.
|
|
310
|
+
2. **Inline hidden code first.** Copy logic from before_actions, helper methods, and parent classes into each action so you can see the full picture.
|
|
311
|
+
3. **Classify each block** using the decision framework table above.
|
|
312
|
+
4. **Extract in order**: RESTful resource extraction first (new controllers), then service objects, query objects, form objects, presenters, and finally concerns.
|
|
313
|
+
5. **Slim down each action** to: find/build the resource, call a service or query, render or redirect. Each action should be 5-10 lines.
|
|
314
|
+
6. **Clean up strong params.** Each controller should have one `_params` method. If you need different permitted params per action, consider separate controllers or form objects.
|
|
315
|
+
7. **Create or update tests** for each extracted class with its own spec file.
|
|
316
|
+
|
|
317
|
+
## What the Controller Should Look Like After
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
class PlayersController < ApplicationController
|
|
321
|
+
before_action :set_player, only: [:show, :edit, :update, :destroy]
|
|
322
|
+
|
|
323
|
+
def index
|
|
324
|
+
@players = Players::FilterQuery.new(params).call
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def show
|
|
328
|
+
@presenter = PlayerDashboardPresenter.new(@player)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def create
|
|
332
|
+
result = Players::SignPlayer.new(player_params, team_id: params[:team_id]).call
|
|
333
|
+
|
|
334
|
+
if result.success?
|
|
335
|
+
redirect_to result.player, notice: "Player signed"
|
|
336
|
+
else
|
|
337
|
+
@player = result.player
|
|
338
|
+
render :new, status: :unprocessable_entity
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def update
|
|
343
|
+
if @player.update(player_params)
|
|
344
|
+
redirect_to @player, notice: "Player updated"
|
|
345
|
+
else
|
|
346
|
+
render :edit, status: :unprocessable_entity
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def destroy
|
|
351
|
+
@player.destroy
|
|
352
|
+
redirect_to players_path, notice: "Player removed"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
private
|
|
356
|
+
|
|
357
|
+
def set_player
|
|
358
|
+
@player = Player.find(params[:id])
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def player_params
|
|
362
|
+
params.require(:player).permit(:name, :position, :age, :team_id)
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
## What NOT to Do
|
|
368
|
+
|
|
369
|
+
- Don't put business logic in before_actions — they obscure the flow of an action.
|
|
370
|
+
- Don't use instance variables to pass data between before_actions and actions in complex ways.
|
|
371
|
+
- Don't rescue broad exceptions in individual actions — use a concern or `rescue_from`.
|
|
372
|
+
- Don't add non-RESTful actions to a controller when a new controller would be clearer.
|
|
373
|
+
- Don't create deeply nested service call chains — one service per action is the goal.
|
|
374
|
+
- Don't move logic to private methods and call it refactored — private methods still live in the controller.
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: action-view-best-practices
|
|
3
|
+
description: Build or Refactor Rails views, partials, and templates into clean, maintainable code. Use when views have inline Ruby logic, deeply nested partials, jQuery or legacy JavaScript, helper methods returning HTML, or when the user asks to modernize, refactor, or clean up Rails views. Applies patterns - Turbo Frames, Turbo Streams, Stimulus controllers, ViewComponent, presenters, strict locals, and proper partial extraction.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Rails View Refactoring
|
|
7
|
+
|
|
8
|
+
Views should contain markup and minimal display logic. If a view has conditionals, calculations, query calls, or complex Ruby blocks, it needs refactoring. The modern Rails 8+ stack uses Hotwire (Turbo + Stimulus) for interactivity, Propshaft for assets, and Importmap for JavaScript — no build step required.
|
|
9
|
+
|
|
10
|
+
## Decision Framework
|
|
11
|
+
|
|
12
|
+
Read the view and classify each block of code:
|
|
13
|
+
|
|
14
|
+
| Code type | Extract to | Location |
|
|
15
|
+
| ------------------------------------------------------- | ------------------------------- | ------------------------------------------ |
|
|
16
|
+
| Reusable UI patterns (buttons, cards, modals, badges) | ViewComponent | `app/components/` |
|
|
17
|
+
| Display logic (formatting, conditional CSS, label text) | Presenter or ViewComponent | `app/presenters/` or `app/components/` |
|
|
18
|
+
| HTML-returning helper methods | ViewComponent | `app/components/` |
|
|
19
|
+
| Inline `<script>` tags and jQuery | Stimulus controller | `app/javascript/controllers/` |
|
|
20
|
+
| AJAX calls, remote forms, `$.ajax` | Turbo Frame or Turbo Stream | ERB template + controller response |
|
|
21
|
+
| Partial page updates via JavaScript | Turbo Frame | Wrap in `turbo_frame_tag` |
|
|
22
|
+
| Real-time broadcasts (chat, notifications) | Turbo Stream | Model `broadcasts_to` or controller stream |
|
|
23
|
+
| One-off page sections that are too long | Partial with strict locals | `app/views/shared/` or alongside view |
|
|
24
|
+
| Complex data assembly for the view | Presenter | `app/presenters/` |
|
|
25
|
+
| Repeated inline Ruby (loops with logic) | Collection partial or component | Partial or component |
|
|
26
|
+
| Instance variables used across partials | Locals / strict locals | Pass explicitly |
|
|
27
|
+
|
|
28
|
+
## Modernizing to Hotwire
|
|
29
|
+
|
|
30
|
+
### Replace jQuery AJAX with Turbo Frames
|
|
31
|
+
|
|
32
|
+
Turbo Frames update a specific section of the page without a full reload. No JavaScript needed.
|
|
33
|
+
|
|
34
|
+
```erb
|
|
35
|
+
<%# Before — jQuery AJAX %>
|
|
36
|
+
<div id="player-stats"></div>
|
|
37
|
+
<script>
|
|
38
|
+
$.get('/players/<%= @player.id %>/stats', function(data) {
|
|
39
|
+
$('#player-stats').html(data);
|
|
40
|
+
});
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<%# After — Turbo Frame %>
|
|
44
|
+
<%= turbo_frame_tag "player_stats" do %>
|
|
45
|
+
<%= render partial: "players/stats", locals: { player: @player } %>
|
|
46
|
+
<% end %>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The linked page just needs a matching `turbo_frame_tag` with the same ID and Turbo handles the rest.
|
|
50
|
+
|
|
51
|
+
### Replace Remote Forms with Turbo
|
|
52
|
+
|
|
53
|
+
Rails UJS `remote: true` forms are deprecated. Turbo handles forms natively.
|
|
54
|
+
|
|
55
|
+
```erb
|
|
56
|
+
<%# Before — Rails UJS %>
|
|
57
|
+
<%= form_with model: @player, remote: true do |f| %>
|
|
58
|
+
...
|
|
59
|
+
<% end %>
|
|
60
|
+
|
|
61
|
+
<%# After — Turbo (just remove remote: true, Turbo handles it) %>
|
|
62
|
+
<%= form_with model: @player do |f| %>
|
|
63
|
+
...
|
|
64
|
+
<% end %>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Turbo intercepts all form submissions by default. For partial updates, wrap the form in a `turbo_frame_tag`. For multi-target updates, respond with a Turbo Stream:
|
|
68
|
+
|
|
69
|
+
```erb
|
|
70
|
+
<%# app/views/players/update.turbo_stream.erb %>
|
|
71
|
+
<%= turbo_stream.replace "player_header" do %>
|
|
72
|
+
<%= render partial: "players/header", locals: { player: @player } %>
|
|
73
|
+
<% end %>
|
|
74
|
+
|
|
75
|
+
<%= turbo_stream.update "flash_messages" do %>
|
|
76
|
+
<%= render partial: "shared/flash" %>
|
|
77
|
+
<% end %>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Replace Inline JavaScript with Stimulus
|
|
81
|
+
|
|
82
|
+
Any behavior attached to DOM elements (toggles, dropdowns, form validation, clipboard, modals) should be a Stimulus controller.
|
|
83
|
+
|
|
84
|
+
```erb
|
|
85
|
+
<%# Before — inline JS / jQuery %>
|
|
86
|
+
<button onclick="document.getElementById('details').classList.toggle('hidden')">
|
|
87
|
+
Toggle
|
|
88
|
+
</button>
|
|
89
|
+
<div id="details" class="hidden">...</div>
|
|
90
|
+
|
|
91
|
+
<%# After — Stimulus %>
|
|
92
|
+
<div data-controller="toggle">
|
|
93
|
+
<button data-action="click->toggle#switch">Toggle</button>
|
|
94
|
+
<div data-toggle-target="content" class="hidden">...</div>
|
|
95
|
+
</div>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```javascript
|
|
99
|
+
// app/javascript/controllers/toggle_controller.js
|
|
100
|
+
import { Controller } from "@hotwired/stimulus";
|
|
101
|
+
|
|
102
|
+
export default class extends Controller {
|
|
103
|
+
static targets = ["content"];
|
|
104
|
+
|
|
105
|
+
switch() {
|
|
106
|
+
this.contentTarget.classList.toggle("hidden");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Conventions:
|
|
112
|
+
|
|
113
|
+
- One behavior per controller — keep them small and composable
|
|
114
|
+
- Name controllers after what they do: `toggle`, `clipboard`, `dropdown`, `search-form`
|
|
115
|
+
- Use `targets` for elements, `values` for data, `classes` for CSS class names
|
|
116
|
+
- Never manipulate DOM outside the controller's element scope
|
|
117
|
+
|
|
118
|
+
### Replace Polling with Turbo Streams
|
|
119
|
+
|
|
120
|
+
For real-time updates, use Turbo Streams over WebSockets instead of JavaScript polling.
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
# app/models/score.rb
|
|
124
|
+
class Score < ApplicationRecord
|
|
125
|
+
broadcasts_to ->(score) { [score.game] }, inserts_by: :prepend
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
```erb
|
|
130
|
+
<%# Subscribe in the view %>
|
|
131
|
+
<%= turbo_stream_from @game %>
|
|
132
|
+
|
|
133
|
+
<div id="scores">
|
|
134
|
+
<%= render @game.scores %>
|
|
135
|
+
</div>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
New scores automatically appear without any JavaScript.
|
|
139
|
+
|
|
140
|
+
## View Component Patterns
|
|
141
|
+
|
|
142
|
+
### When to Use ViewComponent vs Partials
|
|
143
|
+
|
|
144
|
+
Use **partials** for:
|
|
145
|
+
|
|
146
|
+
- One-off page sections that won't be reused
|
|
147
|
+
- Simple markup extraction to reduce file length
|
|
148
|
+
- Layouts and structural wrappers
|
|
149
|
+
|
|
150
|
+
Use **ViewComponent** for:
|
|
151
|
+
|
|
152
|
+
- Reusable UI elements (buttons, cards, badges, modals, alerts)
|
|
153
|
+
- Components with display logic or multiple variants
|
|
154
|
+
- Anything you'd want to unit test in isolation
|
|
155
|
+
- Complex components with slots for flexible content injection
|
|
156
|
+
|
|
157
|
+
### ViewComponent Example
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
# app/components/stat_card_component.rb
|
|
161
|
+
class StatCardComponent < ViewComponent::Base
|
|
162
|
+
def initialize(label:, value:, trend: nil)
|
|
163
|
+
@label = label
|
|
164
|
+
@value = value
|
|
165
|
+
@trend = trend
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def trend_class
|
|
169
|
+
case @trend
|
|
170
|
+
when :up then "text-green-600"
|
|
171
|
+
when :down then "text-red-600"
|
|
172
|
+
else "text-gray-500"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```erb
|
|
179
|
+
<%# app/components/stat_card_component.html.erb %>
|
|
180
|
+
<div class="rounded-lg border p-4">
|
|
181
|
+
<dt class="text-sm text-gray-500"><%= @label %></dt>
|
|
182
|
+
<dd class="text-2xl font-semibold"><%= @value %></dd>
|
|
183
|
+
<% if @trend %>
|
|
184
|
+
<span class="<%= trend_class %>"><%= @trend == :up ? "↑" : "↓" %></span>
|
|
185
|
+
<% end %>
|
|
186
|
+
</div>
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
```erb
|
|
190
|
+
<%# Usage %>
|
|
191
|
+
<%= render StatCardComponent.new(label: "Batting Avg", value: ".312", trend: :up) %>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Partial Best Practices
|
|
195
|
+
|
|
196
|
+
### Use Strict Locals
|
|
197
|
+
|
|
198
|
+
Always declare expected locals at the top of partials. This was added in Rails 7.1 and prevents silent nil bugs.
|
|
199
|
+
|
|
200
|
+
```erb
|
|
201
|
+
<%# app/views/players/_card.html.erb %>
|
|
202
|
+
<%# locals: (player:, show_stats: false) %>
|
|
203
|
+
|
|
204
|
+
<div class="player-card">
|
|
205
|
+
<h3><%= player.name %></h3>
|
|
206
|
+
<% if show_stats %>
|
|
207
|
+
<%= render partial: "players/stats", locals: { player: player } %>
|
|
208
|
+
<% end %>
|
|
209
|
+
</div>
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Use Collection Rendering
|
|
213
|
+
|
|
214
|
+
Never loop and render partials manually.
|
|
215
|
+
|
|
216
|
+
```erb
|
|
217
|
+
<%# Before — slow, verbose %>
|
|
218
|
+
<% @players.each do |player| %>
|
|
219
|
+
<%= render partial: "players/card", locals: { player: player } %>
|
|
220
|
+
<% end %>
|
|
221
|
+
|
|
222
|
+
<%# After — collection rendering (faster, cleaner) %>
|
|
223
|
+
<%= render partial: "players/card", collection: @players, as: :player %>
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Avoid Deeply Nested Partials
|
|
227
|
+
|
|
228
|
+
If partial A renders partial B which renders partial C, it's too deep. Flatten the structure or extract to a ViewComponent that composes its own sub-components.
|
|
229
|
+
|
|
230
|
+
## Presenters for Complex View Logic
|
|
231
|
+
|
|
232
|
+
When a view needs data from multiple sources or complex formatting, use a presenter instead of cramming logic into the template.
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
# app/presenters/player_profile_presenter.rb
|
|
236
|
+
class PlayerProfilePresenter
|
|
237
|
+
def initialize(player, current_user)
|
|
238
|
+
@player = player
|
|
239
|
+
@current_user = current_user
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def display_name
|
|
243
|
+
"#{@player.first_name} #{@player.last_name}"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def contract_status_badge
|
|
247
|
+
if @player.free_agent?
|
|
248
|
+
{ text: "Free Agent", color: "green" }
|
|
249
|
+
elsif @player.contract_years_remaining <= 1
|
|
250
|
+
{ text: "Expiring", color: "yellow" }
|
|
251
|
+
else
|
|
252
|
+
{ text: "Under Contract", color: "gray" }
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def can_edit?
|
|
257
|
+
@current_user.admin? || @current_user.team == @player.team
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def formatted_salary
|
|
261
|
+
ActiveSupport::NumberHelper.number_to_currency(@player.salary)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
```erb
|
|
267
|
+
<%# Clean view %>
|
|
268
|
+
<h1><%= @presenter.display_name %></h1>
|
|
269
|
+
|
|
270
|
+
<% badge = @presenter.contract_status_badge %>
|
|
271
|
+
<%= render BadgeComponent.new(text: badge[:text], color: badge[:color]) %>
|
|
272
|
+
|
|
273
|
+
<%= @presenter.formatted_salary %>
|
|
274
|
+
|
|
275
|
+
<% if @presenter.can_edit? %>
|
|
276
|
+
<%= link_to "Edit", edit_player_path(@player) %>
|
|
277
|
+
<% end %>
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Eliminating Helper Abuse
|
|
281
|
+
|
|
282
|
+
Rails helpers that return HTML are hard to test, hard to read, and hard to compose. Move them to ViewComponents.
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
# Before — helper returning HTML (bad)
|
|
286
|
+
module PlayersHelper
|
|
287
|
+
def player_avatar(player, size: :md)
|
|
288
|
+
sizes = { sm: "w-8 h-8", md: "w-12 h-12", lg: "w-16 h-16" }
|
|
289
|
+
if player.avatar.attached?
|
|
290
|
+
image_tag player.avatar, class: "rounded-full #{sizes[size]}"
|
|
291
|
+
else
|
|
292
|
+
content_tag :div, player.initials,
|
|
293
|
+
class: "rounded-full #{sizes[size]} bg-gray-300 flex items-center justify-center"
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# After — ViewComponent (good)
|
|
299
|
+
# app/components/avatar_component.rb
|
|
300
|
+
class AvatarComponent < ViewComponent::Base
|
|
301
|
+
SIZES = { sm: "w-8 h-8", md: "w-12 h-12", lg: "w-16 h-16" }.freeze
|
|
302
|
+
|
|
303
|
+
def initialize(player:, size: :md)
|
|
304
|
+
@player = player
|
|
305
|
+
@size = size
|
|
306
|
+
end
|
|
307
|
+
# ... with its own template and tests
|
|
308
|
+
end
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Keep helpers for non-HTML formatting utilities only (number formatting, date formatting, text truncation).
|
|
312
|
+
|
|
313
|
+
## Refactoring Process
|
|
314
|
+
|
|
315
|
+
1. **Audit the view layer** — identify inline Ruby logic, jQuery, `remote: true` forms, helpers returning HTML, and deeply nested partials.
|
|
316
|
+
2. **Remove jQuery and inline JS first** — replace with Stimulus controllers. This is the highest-impact change.
|
|
317
|
+
3. **Replace `remote: true` and AJAX** — Turbo handles forms and links natively. Convert to Turbo Frames and Turbo Streams.
|
|
318
|
+
4. **Add strict locals** to all existing partials.
|
|
319
|
+
5. **Extract reusable UI patterns** into ViewComponents.
|
|
320
|
+
6. **Move display logic** from views into presenters or component classes.
|
|
321
|
+
7. **Move HTML-returning helpers** into components.
|
|
322
|
+
8. **Flatten nested partials** — if nesting is deeper than 2 levels, restructure.
|
|
323
|
+
9. **Add collection rendering** wherever loops render partials.
|
|
324
|
+
10. **Remove all instance variables from partials** — pass data via locals only.
|
|
325
|
+
|
|
326
|
+
## What NOT to Do
|
|
327
|
+
|
|
328
|
+
- Don't put query calls in views — ever. Not even `count` or `any?`. Use the presenter or controller.
|
|
329
|
+
- Don't use `content_for` for complex logic — it creates invisible dependencies between layouts and views.
|
|
330
|
+
- Don't create Stimulus controllers that replicate what Turbo Frames already handle.
|
|
331
|
+
- Don't mix jQuery and Stimulus in the same app — commit to full migration.
|
|
332
|
+
- Don't render entire pages as ViewComponents — they are for reusable pieces, not whole pages.
|
|
333
|
+
- Don't use `html_safe` or `raw` unless you are certain the content is sanitized.
|
|
334
|
+
- Don't pass more than 3-4 locals to a partial — if you need more, it should be a component or presenter.
|
|
335
|
+
- Don't use `render partial:` inside loops — use collection rendering instead.
|