ariadna 1.3.0 → 2.0.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.
- checksums.yaml +4 -4
- data/ariadna.gemspec +0 -1
- data/data/agents/ariadna-codebase-mapper.md +34 -722
- data/data/agents/ariadna-debugger.md +44 -1139
- data/data/agents/ariadna-executor.md +75 -396
- data/data/agents/ariadna-planner.md +78 -1215
- data/data/agents/ariadna-roadmapper.md +55 -582
- data/data/agents/ariadna-verifier.md +60 -702
- data/data/ariadna/templates/config.json +8 -33
- data/data/ariadna/workflows/debug.md +28 -0
- data/data/ariadna/workflows/execute-phase.md +31 -513
- data/data/ariadna/workflows/map-codebase.md +20 -319
- data/data/ariadna/workflows/new-milestone.md +20 -365
- data/data/ariadna/workflows/new-project.md +19 -880
- data/data/ariadna/workflows/plan-phase.md +24 -443
- data/data/ariadna/workflows/progress.md +20 -376
- data/data/ariadna/workflows/quick.md +19 -221
- data/data/ariadna/workflows/roadmap-ops.md +28 -0
- data/data/ariadna/workflows/verify-work.md +23 -560
- data/data/commands/ariadna/add-phase.md +11 -22
- data/data/commands/ariadna/debug.md +11 -143
- data/data/commands/ariadna/execute-phase.md +12 -30
- data/data/commands/ariadna/insert-phase.md +7 -14
- data/data/commands/ariadna/map-codebase.md +16 -49
- data/data/commands/ariadna/new-milestone.md +12 -25
- data/data/commands/ariadna/new-project.md +22 -26
- data/data/commands/ariadna/plan-phase.md +13 -22
- data/data/commands/ariadna/progress.md +16 -6
- data/data/commands/ariadna/quick.md +9 -11
- data/data/commands/ariadna/remove-phase.md +9 -12
- data/data/commands/ariadna/verify-work.md +14 -19
- data/data/skills/rails-backend/API.md +138 -0
- data/data/skills/rails-backend/CONTROLLERS.md +154 -0
- data/data/skills/rails-backend/JOBS.md +132 -0
- data/data/skills/rails-backend/MODELS.md +213 -0
- data/data/skills/rails-backend/SKILL.md +169 -0
- data/data/skills/rails-frontend/ASSETS.md +154 -0
- data/data/skills/rails-frontend/COMPONENTS.md +253 -0
- data/data/skills/rails-frontend/SKILL.md +187 -0
- data/data/skills/rails-frontend/VIEWS.md +168 -0
- data/data/skills/rails-performance/PROFILING.md +106 -0
- data/data/skills/rails-performance/SKILL.md +217 -0
- data/data/skills/rails-security/AUDIT.md +118 -0
- data/data/skills/rails-security/SKILL.md +422 -0
- data/data/skills/rails-testing/FIXTURES.md +78 -0
- data/data/skills/rails-testing/SKILL.md +160 -0
- data/data/skills/rails-testing/SYSTEM-TESTS.md +73 -0
- data/lib/ariadna/installer.rb +11 -15
- data/lib/ariadna/tools/cli.rb +0 -12
- data/lib/ariadna/tools/config_manager.rb +10 -72
- data/lib/ariadna/tools/frontmatter.rb +23 -1
- data/lib/ariadna/tools/init.rb +201 -401
- data/lib/ariadna/tools/model_profiles.rb +6 -14
- data/lib/ariadna/tools/phase_manager.rb +1 -10
- data/lib/ariadna/tools/state_manager.rb +170 -451
- data/lib/ariadna/tools/template_filler.rb +4 -12
- data/lib/ariadna/tools/verification.rb +21 -399
- data/lib/ariadna/uninstaller.rb +9 -0
- data/lib/ariadna/version.rb +1 -1
- data/lib/ariadna.rb +1 -0
- metadata +20 -91
- data/data/agents/ariadna-backend-executor.md +0 -261
- data/data/agents/ariadna-frontend-executor.md +0 -259
- data/data/agents/ariadna-integration-checker.md +0 -418
- data/data/agents/ariadna-phase-researcher.md +0 -469
- data/data/agents/ariadna-plan-checker.md +0 -622
- data/data/agents/ariadna-project-researcher.md +0 -618
- data/data/agents/ariadna-research-synthesizer.md +0 -236
- data/data/agents/ariadna-test-executor.md +0 -266
- data/data/ariadna/references/checkpoints.md +0 -772
- data/data/ariadna/references/continuation-format.md +0 -249
- data/data/ariadna/references/decimal-phase-calculation.md +0 -65
- data/data/ariadna/references/git-integration.md +0 -248
- data/data/ariadna/references/git-planning-commit.md +0 -38
- data/data/ariadna/references/model-profile-resolution.md +0 -32
- data/data/ariadna/references/model-profiles.md +0 -73
- data/data/ariadna/references/phase-argument-parsing.md +0 -61
- data/data/ariadna/references/planning-config.md +0 -194
- data/data/ariadna/references/questioning.md +0 -153
- data/data/ariadna/references/rails-conventions.md +0 -416
- data/data/ariadna/references/tdd.md +0 -267
- data/data/ariadna/references/ui-brand.md +0 -160
- data/data/ariadna/references/verification-patterns.md +0 -853
- data/data/ariadna/templates/codebase/architecture.md +0 -481
- data/data/ariadna/templates/codebase/concerns.md +0 -380
- data/data/ariadna/templates/codebase/conventions.md +0 -434
- data/data/ariadna/templates/codebase/integrations.md +0 -328
- data/data/ariadna/templates/codebase/stack.md +0 -189
- data/data/ariadna/templates/codebase/structure.md +0 -418
- data/data/ariadna/templates/codebase/testing.md +0 -606
- data/data/ariadna/templates/context.md +0 -283
- data/data/ariadna/templates/continue-here.md +0 -78
- data/data/ariadna/templates/debug-subagent-prompt.md +0 -91
- data/data/ariadna/templates/phase-prompt.md +0 -609
- data/data/ariadna/templates/planner-subagent-prompt.md +0 -117
- data/data/ariadna/templates/research-project/ARCHITECTURE.md +0 -439
- data/data/ariadna/templates/research-project/FEATURES.md +0 -168
- data/data/ariadna/templates/research-project/PITFALLS.md +0 -406
- data/data/ariadna/templates/research-project/STACK.md +0 -251
- data/data/ariadna/templates/research-project/SUMMARY.md +0 -247
- data/data/ariadna/templates/state.md +0 -176
- data/data/ariadna/templates/summary-complex.md +0 -59
- data/data/ariadna/templates/summary-minimal.md +0 -41
- data/data/ariadna/templates/summary-standard.md +0 -48
- data/data/ariadna/templates/user-setup.md +0 -310
- data/data/ariadna/workflows/add-phase.md +0 -111
- data/data/ariadna/workflows/add-todo.md +0 -157
- data/data/ariadna/workflows/audit-milestone.md +0 -241
- data/data/ariadna/workflows/check-todos.md +0 -176
- data/data/ariadna/workflows/complete-milestone.md +0 -644
- data/data/ariadna/workflows/diagnose-issues.md +0 -219
- data/data/ariadna/workflows/discovery-phase.md +0 -289
- data/data/ariadna/workflows/discuss-phase.md +0 -408
- data/data/ariadna/workflows/execute-plan.md +0 -448
- data/data/ariadna/workflows/help.md +0 -470
- data/data/ariadna/workflows/insert-phase.md +0 -129
- data/data/ariadna/workflows/list-phase-assumptions.md +0 -178
- data/data/ariadna/workflows/pause-work.md +0 -122
- data/data/ariadna/workflows/plan-milestone-gaps.md +0 -256
- data/data/ariadna/workflows/remove-phase.md +0 -154
- data/data/ariadna/workflows/research-phase.md +0 -74
- data/data/ariadna/workflows/resume-project.md +0 -306
- data/data/ariadna/workflows/set-profile.md +0 -80
- data/data/ariadna/workflows/settings.md +0 -145
- data/data/ariadna/workflows/transition.md +0 -493
- data/data/ariadna/workflows/update.md +0 -212
- data/data/ariadna/workflows/verify-phase.md +0 -226
- data/data/commands/ariadna/add-todo.md +0 -42
- data/data/commands/ariadna/audit-milestone.md +0 -42
- data/data/commands/ariadna/check-todos.md +0 -41
- data/data/commands/ariadna/complete-milestone.md +0 -136
- data/data/commands/ariadna/discuss-phase.md +0 -86
- data/data/commands/ariadna/help.md +0 -22
- data/data/commands/ariadna/list-phase-assumptions.md +0 -50
- data/data/commands/ariadna/pause-work.md +0 -35
- data/data/commands/ariadna/plan-milestone-gaps.md +0 -40
- data/data/commands/ariadna/reapply-patches.md +0 -110
- data/data/commands/ariadna/research-phase.md +0 -187
- data/data/commands/ariadna/resume-work.md +0 -40
- data/data/commands/ariadna/set-profile.md +0 -34
- data/data/commands/ariadna/settings.md +0 -36
- data/data/commands/ariadna/update.md +0 -37
- data/data/guides/backend.md +0 -3069
- data/data/guides/frontend.md +0 -1479
- data/data/guides/performance.md +0 -1193
- data/data/guides/security.md +0 -1522
- data/data/guides/style-guide.md +0 -1091
- data/data/guides/testing.md +0 -504
- data/data/templates.md +0 -94
data/data/guides/frontend.md
DELETED
|
@@ -1,1479 +0,0 @@
|
|
|
1
|
-
# Frontend Patterns
|
|
2
|
-
|
|
3
|
-
**View Layer Conventions for Rails Applications**
|
|
4
|
-
|
|
5
|
-
This guide covers the frontend and view layer patterns used in Rails applications: the Presenter Pattern, Turbo (Drive, Frames, Streams), Stimulus controllers, and view template conventions.
|
|
6
|
-
|
|
7
|
-
**Related guides:**
|
|
8
|
-
- [Backend Patterns](backend.md) — Architecture, models, controllers, jobs, style guide
|
|
9
|
-
- [Testing Patterns](testing.md) — Testing philosophy, model/controller/job test patterns
|
|
10
|
-
- [Security Guide](security.md) — Agent-oriented security checklist for code review
|
|
11
|
-
- See `data/guides/style-guide.md` for CSS architecture and design tokens
|
|
12
|
-
|
|
13
|
-
## Table of Contents
|
|
14
|
-
|
|
15
|
-
- [1. Presenter Pattern](#1-presenter-pattern)
|
|
16
|
-
- [1.1 Philosophy](#11-philosophy)
|
|
17
|
-
- [1.2 When to Create a Presenter](#12-when-to-create-a-presenter)
|
|
18
|
-
- [1.3 Anatomy of a Presenter](#13-anatomy-of-a-presenter)
|
|
19
|
-
- [1.4 Generating HTML in Presenters](#14-generating-html-in-presenters)
|
|
20
|
-
- [1.5 Nested Presenters](#15-nested-presenters)
|
|
21
|
-
- [1.6 Instantiation Patterns](#16-instantiation-patterns)
|
|
22
|
-
- [1.7 View Usage](#17-view-usage)
|
|
23
|
-
- [1.8 Testing Presenters](#18-testing-presenters)
|
|
24
|
-
- [1.9 Real Examples](#19-real-examples)
|
|
25
|
-
- [2. Turbo Streams & Turbo Frames](#2-turbo-streams--turbo-frames)
|
|
26
|
-
- [2.1 Turbo Drive Essentials](#21-turbo-drive-essentials)
|
|
27
|
-
- [2.2 Turbo Frames](#22-turbo-frames)
|
|
28
|
-
- [2.3 Turbo Streams](#23-turbo-streams)
|
|
29
|
-
- [2.4 Optimistic UI](#24-optimistic-ui)
|
|
30
|
-
- [2.5 HTTP Response Conventions](#25-http-response-conventions)
|
|
31
|
-
- [2.6 View Transitions](#26-view-transitions)
|
|
32
|
-
- [3. Stimulus Controllers](#3-stimulus-controllers)
|
|
33
|
-
- [3.1 Controller Architecture](#31-controller-architecture)
|
|
34
|
-
- [3.2 Lifecycle](#32-lifecycle)
|
|
35
|
-
- [3.3 Values (Reactive State)](#33-values-reactive-state)
|
|
36
|
-
- [3.4 Targets (DOM References)](#34-targets-dom-references)
|
|
37
|
-
- [3.5 Outlets (Controller-to-Controller Communication)](#35-outlets-controller-to-controller-communication)
|
|
38
|
-
- [3.6 Actions & Parameters](#36-actions--parameters)
|
|
39
|
-
- [3.7 Common Patterns](#37-common-patterns)
|
|
40
|
-
- [4. View Templates & Partials](#4-view-templates--partials)
|
|
41
|
-
- [4.1 ERB Conventions](#41-erb-conventions)
|
|
42
|
-
- [4.2 Partial Extraction Rules](#42-partial-extraction-rules)
|
|
43
|
-
- [4.3 Turbo Frame Wrapping in Views](#43-turbo-frame-wrapping-in-views)
|
|
44
|
-
- [4.4 Template-Based DOM Patterns](#44-template-based-dom-patterns)
|
|
45
|
-
- [4.5 Cache-Safe Views](#45-cache-safe-views)
|
|
46
|
-
|
|
47
|
-
---
|
|
48
|
-
|
|
49
|
-
# 1. Presenter Pattern
|
|
50
|
-
|
|
51
|
-
It uses presenter classes to encapsulate complex view logic. Unlike typical Rails apps, presenters don't live in an `app/presenters/` directory—they live in `app/models/` organized by domain, aligning with "vanilla Rails" philosophy.
|
|
52
|
-
|
|
53
|
-
## 1.1 Philosophy
|
|
54
|
-
|
|
55
|
-
Presenters are plain Ruby classes that:
|
|
56
|
-
- Package data and display logic for views
|
|
57
|
-
- Transform domain objects into view-ready formats
|
|
58
|
-
- Encapsulate conditional display logic
|
|
59
|
-
- Provide cache keys for fragment caching
|
|
60
|
-
|
|
61
|
-
**Why models layer?** Presenters are domain objects that know about business rules. They fit naturally alongside concerns like `User::Filtering` and `Event::Description`. No separate presenter infrastructure is needed.
|
|
62
|
-
|
|
63
|
-
## 1.2 When to Create a Presenter
|
|
64
|
-
|
|
65
|
-
**Create a presenter when:**
|
|
66
|
-
- A view needs complex conditional logic (3+ conditions)
|
|
67
|
-
- Multiple related values need to be computed together
|
|
68
|
-
- You need to transform data into HTML or formatted text
|
|
69
|
-
- The same display logic is needed in multiple views
|
|
70
|
-
- You want to cache complex view fragments
|
|
71
|
-
|
|
72
|
-
**Don't create a presenter when:**
|
|
73
|
-
- Simple delegation would suffice (use helpers)
|
|
74
|
-
- You only need one or two computed values
|
|
75
|
-
- The logic is purely formatting (use view helpers)
|
|
76
|
-
|
|
77
|
-
## 1.3 Anatomy of a Presenter
|
|
78
|
-
|
|
79
|
-
**File**: `app/models/user/filtering.rb`
|
|
80
|
-
|
|
81
|
-
```ruby
|
|
82
|
-
class User::Filtering
|
|
83
|
-
attr_reader :user, :filter, :expanded
|
|
84
|
-
|
|
85
|
-
delegate :as_params, :single_board, to: :filter
|
|
86
|
-
delegate :only_closed?, to: :filter
|
|
87
|
-
|
|
88
|
-
def initialize(user, filter, expanded: false)
|
|
89
|
-
@user, @filter, @expanded = user, filter, expanded
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# Memoized collections (lazy-loaded)
|
|
93
|
-
def boards
|
|
94
|
-
@boards ||= user.boards.ordered_by_recently_accessed
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def tags
|
|
98
|
-
@tags ||= account.tags.all.alphabetically
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def users
|
|
102
|
-
@users ||= account.users.active.alphabetically
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
# Boolean methods for conditional display
|
|
106
|
-
def expanded?
|
|
107
|
-
@expanded
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def any?
|
|
111
|
-
filter.used?(ignore_boards: true)
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def show_tags?
|
|
115
|
-
return unless Tag.any?
|
|
116
|
-
filter.tags.any?
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def show_assignees?
|
|
120
|
-
filter.assignees.any?
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
# Cache key for fragment caching
|
|
124
|
-
def cache_key
|
|
125
|
-
ActiveSupport::Cache.expand_cache_key(
|
|
126
|
-
[ user, filter, expanded?, boards, tags, users, filters ],
|
|
127
|
-
"user-filtering"
|
|
128
|
-
)
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
private
|
|
132
|
-
def account
|
|
133
|
-
user.account
|
|
134
|
-
end
|
|
135
|
-
end
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
**Pattern breakdown:**
|
|
139
|
-
|
|
140
|
-
1. **Plain Ruby class** - No framework, no gem, no inheritance
|
|
141
|
-
2. **Explicit dependencies** - All inputs via constructor
|
|
142
|
-
3. **Memoization** - `@var ||=` for lazy-loaded collections
|
|
143
|
-
4. **Boolean methods** - `show_tags?`, `expanded?` for conditional display
|
|
144
|
-
5. **Cache key** - Composite key for fragment caching
|
|
145
|
-
6. **Private helpers** - Keep the interface clean
|
|
146
|
-
|
|
147
|
-
## 1.4 Generating HTML in Presenters
|
|
148
|
-
|
|
149
|
-
When presenters need to generate HTML, include ActionView helpers:
|
|
150
|
-
|
|
151
|
-
**File**: `app/models/event/description.rb`
|
|
152
|
-
|
|
153
|
-
```ruby
|
|
154
|
-
class Event::Description
|
|
155
|
-
include ActionView::Helpers::TagHelper # ← For tag.span, etc.
|
|
156
|
-
include ERB::Util # ← For h() escaping
|
|
157
|
-
|
|
158
|
-
attr_reader :event, :user
|
|
159
|
-
|
|
160
|
-
def initialize(event, user)
|
|
161
|
-
@event = event
|
|
162
|
-
@user = user
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
def to_html
|
|
166
|
-
to_sentence(creator_tag, card_title_tag).html_safe
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def to_plain_text
|
|
170
|
-
to_sentence(creator_name, quoted(card.title))
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
private
|
|
174
|
-
def creator_tag
|
|
175
|
-
tag.span data: { creator_id: event.creator.id } do
|
|
176
|
-
tag.span("You", data: { only_visible_to_you: true }) +
|
|
177
|
-
tag.span(event.creator.name, data: { only_visible_to_others: true })
|
|
178
|
-
end
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def card_title_tag
|
|
182
|
-
tag.span card.title, class: "txt-underline"
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
# ... action-specific sentence methods ...
|
|
186
|
-
end
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
**Key patterns:**
|
|
190
|
-
- `to_html` / `to_plain_text` for multiple output formats
|
|
191
|
-
- Include only the helpers you need
|
|
192
|
-
- Use `h()` for escaping user content
|
|
193
|
-
- Keep HTML generation in private methods
|
|
194
|
-
|
|
195
|
-
## 1.5 Nested Presenters
|
|
196
|
-
|
|
197
|
-
Presenters can create other presenters for sub-components:
|
|
198
|
-
|
|
199
|
-
**File**: `app/models/user/day_timeline.rb`
|
|
200
|
-
|
|
201
|
-
```ruby
|
|
202
|
-
class User::DayTimeline
|
|
203
|
-
def added_column
|
|
204
|
-
@added_column ||= build_column(:added, "Added", 1,
|
|
205
|
-
events.where(action: %w[card_published card_reopened]))
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
def updated_column
|
|
209
|
-
@updated_column ||= build_column(:updated, "Updated", 2,
|
|
210
|
-
events.where.not(action: %w[card_published card_closed card_reopened]))
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
def closed_column
|
|
214
|
-
@closed_column ||= build_column(:closed, "Done", 3,
|
|
215
|
-
events.where(action: "card_closed"))
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
private
|
|
219
|
-
def build_column(id, base_title, index, events)
|
|
220
|
-
Column.new(self, id, base_title, index, events) # ← Nested presenter
|
|
221
|
-
end
|
|
222
|
-
end
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
**File**: `app/models/user/day_timeline/column.rb`
|
|
226
|
-
|
|
227
|
-
```ruby
|
|
228
|
-
class User::DayTimeline::Column
|
|
229
|
-
include ActionView::Helpers::TagHelper, ActionView::Helpers::OutputSafetyHelper
|
|
230
|
-
|
|
231
|
-
def title
|
|
232
|
-
date_tag = local_datetime_tag(day_timeline.day, style: :agoorweekday)
|
|
233
|
-
parts = [ base_title, date_tag ]
|
|
234
|
-
parts << tag.span("(#{full_events_count})", class: "font-weight-normal") if full_events_count > 0
|
|
235
|
-
safe_join(parts, " ")
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
def events_by_hour
|
|
239
|
-
limited_events.group_by { it.created_at.hour }
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
def has_more_events?
|
|
243
|
-
limited_events.count < full_events_count
|
|
244
|
-
end
|
|
245
|
-
end
|
|
246
|
-
```
|
|
247
|
-
|
|
248
|
-
## 1.6 Instantiation Patterns
|
|
249
|
-
|
|
250
|
-
### Pattern 1: Controller Concerns
|
|
251
|
-
|
|
252
|
-
For presenters used across multiple controllers, create a concern:
|
|
253
|
-
|
|
254
|
-
**File**: `app/controllers/concerns/filter_scoped.rb`
|
|
255
|
-
|
|
256
|
-
```ruby
|
|
257
|
-
module FilterScoped
|
|
258
|
-
extend ActiveSupport::Concern
|
|
259
|
-
|
|
260
|
-
included do
|
|
261
|
-
before_action :set_filter
|
|
262
|
-
before_action :set_user_filtering
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
private
|
|
266
|
-
def set_filter
|
|
267
|
-
if params[:filter_id].present?
|
|
268
|
-
@filter = Current.user.filters.find(params[:filter_id])
|
|
269
|
-
else
|
|
270
|
-
@filter = Current.user.filters.from_params filter_params
|
|
271
|
-
end
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
def set_user_filtering
|
|
275
|
-
@user_filtering = User::Filtering.new(Current.user, @filter, expanded: expanded_param)
|
|
276
|
-
end
|
|
277
|
-
end
|
|
278
|
-
```
|
|
279
|
-
|
|
280
|
-
**Usage in controllers:**
|
|
281
|
-
|
|
282
|
-
```ruby
|
|
283
|
-
class CardsController < ApplicationController
|
|
284
|
-
include FilterScoped # ← Sets @user_filtering automatically
|
|
285
|
-
|
|
286
|
-
def index
|
|
287
|
-
# @user_filtering is available
|
|
288
|
-
end
|
|
289
|
-
end
|
|
290
|
-
```
|
|
291
|
-
|
|
292
|
-
### Pattern 2: Factory Methods on Models
|
|
293
|
-
|
|
294
|
-
For presenters tied to a specific model, add a factory method:
|
|
295
|
-
|
|
296
|
-
**File**: `app/models/event.rb`
|
|
297
|
-
|
|
298
|
-
```ruby
|
|
299
|
-
class Event < ApplicationRecord
|
|
300
|
-
def description_for(user)
|
|
301
|
-
Event::Description.new(self, user)
|
|
302
|
-
end
|
|
303
|
-
end
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
**Usage in views:**
|
|
307
|
-
|
|
308
|
-
```erb
|
|
309
|
-
<%= event.description_for(Current.user).to_html %>
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
This keeps the API discoverable and maintains the object-oriented style.
|
|
313
|
-
|
|
314
|
-
## 1.7 View Usage
|
|
315
|
-
|
|
316
|
-
**With controller-instantiated presenter:**
|
|
317
|
-
|
|
318
|
-
```erb
|
|
319
|
-
<%# @user_filtering set by FilterScoped concern %>
|
|
320
|
-
|
|
321
|
-
<% if @user_filtering.show_tags? %>
|
|
322
|
-
<div class="filter-tags">
|
|
323
|
-
<% @user_filtering.tags.each do |tag| %>
|
|
324
|
-
<%= render "tag", tag: tag %>
|
|
325
|
-
<% end %>
|
|
326
|
-
</div>
|
|
327
|
-
<% end %>
|
|
328
|
-
|
|
329
|
-
<% if @user_filtering.show_assignees? %>
|
|
330
|
-
<!-- assignees UI -->
|
|
331
|
-
<% end %>
|
|
332
|
-
```
|
|
333
|
-
|
|
334
|
-
**With factory method:**
|
|
335
|
-
|
|
336
|
-
```erb
|
|
337
|
-
<% @events.each do |event| %>
|
|
338
|
-
<div class="event-description">
|
|
339
|
-
<%= event.description_for(Current.user).to_html %>
|
|
340
|
-
</div>
|
|
341
|
-
<% end %>
|
|
342
|
-
```
|
|
343
|
-
|
|
344
|
-
**With fragment caching:**
|
|
345
|
-
|
|
346
|
-
```erb
|
|
347
|
-
<% cache @user_filtering.cache_key do %>
|
|
348
|
-
<!-- expensive view rendering -->
|
|
349
|
-
<% end %>
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
## 1.8 Testing Presenters
|
|
353
|
-
|
|
354
|
-
Test presenters like any Ruby class:
|
|
355
|
-
|
|
356
|
-
```ruby
|
|
357
|
-
require "test_helper"
|
|
358
|
-
|
|
359
|
-
class User::FilteringTest < ActiveSupport::TestCase
|
|
360
|
-
setup do
|
|
361
|
-
Current.session = sessions(:david)
|
|
362
|
-
@user = users(:david)
|
|
363
|
-
@filter = Filter.new
|
|
364
|
-
end
|
|
365
|
-
|
|
366
|
-
test "boards returns user's boards ordered by access" do
|
|
367
|
-
filtering = User::Filtering.new(@user, @filter)
|
|
368
|
-
|
|
369
|
-
assert_equal @user.boards.ordered_by_recently_accessed, filtering.boards
|
|
370
|
-
end
|
|
371
|
-
|
|
372
|
-
test "show_tags? returns false when no tags selected" do
|
|
373
|
-
filtering = User::Filtering.new(@user, @filter)
|
|
374
|
-
|
|
375
|
-
assert_not filtering.show_tags?
|
|
376
|
-
end
|
|
377
|
-
|
|
378
|
-
test "show_tags? returns true when tags present" do
|
|
379
|
-
@filter.tags = [ tags(:bug) ]
|
|
380
|
-
filtering = User::Filtering.new(@user, @filter)
|
|
381
|
-
|
|
382
|
-
assert filtering.show_tags?
|
|
383
|
-
end
|
|
384
|
-
|
|
385
|
-
test "cache_key changes when filter changes" do
|
|
386
|
-
filtering1 = User::Filtering.new(@user, @filter)
|
|
387
|
-
key1 = filtering1.cache_key
|
|
388
|
-
|
|
389
|
-
@filter.tags = [ tags(:bug) ]
|
|
390
|
-
filtering2 = User::Filtering.new(@user, @filter)
|
|
391
|
-
key2 = filtering2.cache_key
|
|
392
|
-
|
|
393
|
-
assert_not_equal key1, key2
|
|
394
|
-
end
|
|
395
|
-
end
|
|
396
|
-
```
|
|
397
|
-
|
|
398
|
-
For presenters that generate HTML:
|
|
399
|
-
|
|
400
|
-
```ruby
|
|
401
|
-
class Event::DescriptionTest < ActiveSupport::TestCase
|
|
402
|
-
test "to_html includes creator name" do
|
|
403
|
-
event = events(:card_closed)
|
|
404
|
-
description = Event::Description.new(event, users(:david))
|
|
405
|
-
|
|
406
|
-
assert_includes description.to_html, event.creator.name
|
|
407
|
-
end
|
|
408
|
-
|
|
409
|
-
test "to_plain_text is safe for notifications" do
|
|
410
|
-
event = events(:card_closed)
|
|
411
|
-
description = Event::Description.new(event, users(:david))
|
|
412
|
-
|
|
413
|
-
# No HTML tags in plain text
|
|
414
|
-
assert_no_match /<[^>]+>/, description.to_plain_text
|
|
415
|
-
end
|
|
416
|
-
end
|
|
417
|
-
```
|
|
418
|
-
|
|
419
|
-
## 1.9 Real Examples
|
|
420
|
-
|
|
421
|
-
| Presenter | File | Purpose |
|
|
422
|
-
|-----------|------|---------|
|
|
423
|
-
| `User::Filtering` | `app/models/user/filtering.rb` | Filter UI state and collections |
|
|
424
|
-
| `Event::Description` | `app/models/event/description.rb` | Event → human-readable text |
|
|
425
|
-
| `User::DayTimeline` | `app/models/user/day_timeline.rb` | Timeline organization |
|
|
426
|
-
| `User::DayTimeline::Column` | `app/models/user/day_timeline/column.rb` | Timeline column with HTML generation |
|
|
427
|
-
|
|
428
|
-
### Summary
|
|
429
|
-
|
|
430
|
-
Presenter pattern:
|
|
431
|
-
- **Plain Ruby classes** in `app/models/` (no special directory)
|
|
432
|
-
- **Domain-organized** (`User::Filtering`, not `FilteringPresenter`)
|
|
433
|
-
- **Include ActionView helpers** when generating HTML
|
|
434
|
-
- **Factory methods** on models for discoverable APIs
|
|
435
|
-
- **Controller concerns** for cross-controller instantiation
|
|
436
|
-
- **Memoization** for lazy-loaded collections
|
|
437
|
-
- **Boolean methods** for conditional display
|
|
438
|
-
- **Cache keys** for fragment caching
|
|
439
|
-
|
|
440
|
-
---
|
|
441
|
-
|
|
442
|
-
# 2. Turbo Streams & Turbo Frames
|
|
443
|
-
|
|
444
|
-
## 2.1 Turbo Drive Essentials
|
|
445
|
-
|
|
446
|
-
**Turbo Drive** intercepts link clicks and form submissions, replacing full page loads with fetch requests and DOM swaps. These lifecycle events are the integration points.
|
|
447
|
-
|
|
448
|
-
**`turbo:submit-start`** / **`turbo:submit-end`** — Form activity indicators:
|
|
449
|
-
|
|
450
|
-
```javascript
|
|
451
|
-
document.addEventListener("turbo:submit-start", (event) => {
|
|
452
|
-
const btn = event.target.querySelector("[type=submit]")
|
|
453
|
-
btn.disabled = true
|
|
454
|
-
btn.textContent = "Saving..."
|
|
455
|
-
})
|
|
456
|
-
```
|
|
457
|
-
|
|
458
|
-
**`turbo:before-render`** — Intercept rendering. **Pausable** via `preventDefault()` + `detail.resume()`. Always guard against `data-turbo-preview` to skip animations on cached snapshots:
|
|
459
|
-
|
|
460
|
-
```javascript
|
|
461
|
-
document.addEventListener("turbo:before-render", (event) => {
|
|
462
|
-
if (document.documentElement.hasAttribute("data-turbo-preview")) return
|
|
463
|
-
event.preventDefault()
|
|
464
|
-
document.documentElement.classList.add("page-leaving")
|
|
465
|
-
document.documentElement.addEventListener("animationend", () => event.detail.resume(), { once: true })
|
|
466
|
-
})
|
|
467
|
-
```
|
|
468
|
-
|
|
469
|
-
**`turbo:before-cache`** — Clean transient UI before Turbo snapshots the page. Reset widgets, close dropdowns, clear flash messages:
|
|
470
|
-
|
|
471
|
-
```javascript
|
|
472
|
-
document.addEventListener("turbo:before-cache", () => {
|
|
473
|
-
document.querySelectorAll("[data-dropdown-open]").forEach((el) => el.removeAttribute("data-dropdown-open"))
|
|
474
|
-
document.querySelectorAll(".flash-message").forEach((el) => el.remove())
|
|
475
|
-
})
|
|
476
|
-
```
|
|
477
|
-
|
|
478
|
-
**`turbo:load`** — Page fully loaded and rendered. Equivalent to `DOMContentLoaded` for Turbo navigations.
|
|
479
|
-
|
|
480
|
-
**Progress bar** — Reuse Turbo's built-in bar; style with CSS:
|
|
481
|
-
|
|
482
|
-
```css
|
|
483
|
-
.turbo-progress-bar { height: 3px; background-color: var(--color-accent); }
|
|
484
|
-
```
|
|
485
|
-
|
|
486
|
-
## 2.2 Turbo Frames
|
|
487
|
-
|
|
488
|
-
A **Turbo Frame** (`<turbo-frame>`) scopes navigation to a region of the page. Only the matching frame swaps on navigation.
|
|
489
|
-
|
|
490
|
-
**Wrapping conventions** — Scope frame boundaries to the **smallest rerenderable unit**. Use `dom_id` for IDs:
|
|
491
|
-
|
|
492
|
-
```erb
|
|
493
|
-
<turbo-frame id="<%= dom_id(card) %>">
|
|
494
|
-
<%= render "cards/card", card: card %>
|
|
495
|
-
</turbo-frame>
|
|
496
|
-
```
|
|
497
|
-
|
|
498
|
-
**Lazy loading** — Set `loading="lazy"` with `src` to defer until the frame enters the viewport:
|
|
499
|
-
|
|
500
|
-
```erb
|
|
501
|
-
<turbo-frame id="activity_feed" src="<%= activity_feed_path %>" loading="lazy">
|
|
502
|
-
<p class="loading-placeholder">Loading activity...</p>
|
|
503
|
-
</turbo-frame>
|
|
504
|
-
```
|
|
505
|
-
|
|
506
|
-
**Tabbed navigation** — Drive a content frame from nav links with `data-turbo-frame` and `data-turbo-action="advance"` for history:
|
|
507
|
-
|
|
508
|
-
```erb
|
|
509
|
-
<a href="<%= project_tab_path(@project, tab) %>"
|
|
510
|
-
data-turbo-frame="tab_content"
|
|
511
|
-
data-turbo-action="advance">
|
|
512
|
-
<%= tab.titlecase %>
|
|
513
|
-
</a>
|
|
514
|
-
|
|
515
|
-
<turbo-frame id="tab_content"><%= yield %></turbo-frame>
|
|
516
|
-
```
|
|
517
|
-
|
|
518
|
-
Update active state on **`turbo:frame-load`** (NOT `turbo:click` — click fires before the response):
|
|
519
|
-
|
|
520
|
-
```javascript
|
|
521
|
-
document.addEventListener("turbo:frame-load", (event) => {
|
|
522
|
-
if (event.target.id !== "tab_content") return
|
|
523
|
-
document.querySelectorAll("[data-turbo-frame='tab_content']").forEach((link) => {
|
|
524
|
-
link.classList.toggle("active", link.href === event.target.src)
|
|
525
|
-
})
|
|
526
|
-
})
|
|
527
|
-
```
|
|
528
|
-
|
|
529
|
-
**Pagination with history** — Add `data-turbo-action="advance"` to pagination links so page numbers push to browser history.
|
|
530
|
-
|
|
531
|
-
**Forms in frames** — HTTP status determines behavior:
|
|
532
|
-
- **422** — Turbo swaps the response into the frame (validation errors rendered in place)
|
|
533
|
-
- **303** — Turbo follows the redirect
|
|
534
|
-
|
|
535
|
-
```ruby
|
|
536
|
-
def create
|
|
537
|
-
if @card.save
|
|
538
|
-
redirect_to @card, status: :see_other
|
|
539
|
-
else
|
|
540
|
-
render :new, status: :unprocessable_entity
|
|
541
|
-
end
|
|
542
|
-
end
|
|
543
|
-
```
|
|
544
|
-
|
|
545
|
-
**External form controls** — Use the HTML `form` attribute on inputs outside the `<form>` tag:
|
|
546
|
-
|
|
547
|
-
```erb
|
|
548
|
-
<form id="search_form" action="<%= search_path %>">
|
|
549
|
-
<input type="text" name="query">
|
|
550
|
-
</form>
|
|
551
|
-
<select name="category" form="search_form">...</select>
|
|
552
|
-
```
|
|
553
|
-
|
|
554
|
-
**Loading states** — Style with the `[busy]` attribute Turbo adds automatically:
|
|
555
|
-
|
|
556
|
-
```css
|
|
557
|
-
turbo-frame[busy] { opacity: 0.5; pointer-events: none; }
|
|
558
|
-
```
|
|
559
|
-
|
|
560
|
-
## 2.3 Turbo Streams
|
|
561
|
-
|
|
562
|
-
**Turbo Streams** deliver targeted DOM updates via `<turbo-stream>` elements.
|
|
563
|
-
|
|
564
|
-
**Default actions** — Prefer the 8 built-in actions before writing custom ones: `append`, `prepend`, `replace`, `update`, `remove`, `before`, `after`, `refresh` (Turbo 8 morph).
|
|
565
|
-
|
|
566
|
-
**Custom stream actions** — Register on `StreamActions`. Inside the function, **`this`** is the `<turbo-stream>` element:
|
|
567
|
-
|
|
568
|
-
```javascript
|
|
569
|
-
import { StreamActions } from "@hotwired/turbo"
|
|
570
|
-
|
|
571
|
-
StreamActions.flash = function () {
|
|
572
|
-
const flash = document.createElement("div")
|
|
573
|
-
flash.className = `flash flash--${this.getAttribute("type") || "notice"}`
|
|
574
|
-
flash.textContent = this.getAttribute("message")
|
|
575
|
-
document.getElementById("flash_container").appendChild(flash)
|
|
576
|
-
}
|
|
577
|
-
```
|
|
578
|
-
|
|
579
|
-
**Inline stream tags** — A `<turbo-stream>` appended to the DOM **executes immediately and self-removes**. Store in a `<template>` to prevent premature execution; clone + modify + append:
|
|
580
|
-
|
|
581
|
-
```html
|
|
582
|
-
<template id="optimistic_card_template">
|
|
583
|
-
<turbo-stream action="append" target="cards">
|
|
584
|
-
<template>
|
|
585
|
-
<div id="card_PLACEHOLDER" class="card card--optimistic">
|
|
586
|
-
<span data-title></span>
|
|
587
|
-
</div>
|
|
588
|
-
</template>
|
|
589
|
-
</turbo-stream>
|
|
590
|
-
</template>
|
|
591
|
-
```
|
|
592
|
-
|
|
593
|
-
```javascript
|
|
594
|
-
function appendOptimisticCard(id, title) {
|
|
595
|
-
const stream = document.getElementById("optimistic_card_template").content.cloneNode(true)
|
|
596
|
-
stream.querySelector("[id^='card_']").id = `card_${id}`
|
|
597
|
-
stream.querySelector("[data-title]").textContent = title
|
|
598
|
-
document.body.appendChild(stream)
|
|
599
|
-
}
|
|
600
|
-
```
|
|
601
|
-
|
|
602
|
-
**Broadcast patterns** — Use `after_create_commit` callbacks with broadcast helpers:
|
|
603
|
-
|
|
604
|
-
```ruby
|
|
605
|
-
class Card < ApplicationRecord
|
|
606
|
-
after_create_commit -> { broadcast_append_to board, target: "cards" }
|
|
607
|
-
after_update_commit -> { broadcast_replace_to board }
|
|
608
|
-
after_destroy_commit -> { broadcast_remove_to board }
|
|
609
|
-
end
|
|
610
|
-
```
|
|
611
|
-
|
|
612
|
-
**Turbo 8 morphing** — `turbo_stream.refresh` triggers a full-page morph reconciling the DOM with the server. Use after optimistic UI:
|
|
613
|
-
|
|
614
|
-
```ruby
|
|
615
|
-
format.turbo_stream { render turbo_stream: turbo_stream.refresh }
|
|
616
|
-
```
|
|
617
|
-
|
|
618
|
-
## 2.4 Optimistic UI
|
|
619
|
-
|
|
620
|
-
Render the expected outcome on the client before server confirmation, then reconcile.
|
|
621
|
-
|
|
622
|
-
**Pattern:** Store markup in a `<template>` containing `<turbo-stream>`. On `turbo:submit-start`, clone and append. Server responds with `turbo_stream.refresh` to correct discrepancies.
|
|
623
|
-
|
|
624
|
-
```javascript
|
|
625
|
-
document.addEventListener("turbo:submit-start", (event) => {
|
|
626
|
-
if (event.target.id !== "new_card_form") return
|
|
627
|
-
const title = event.target.querySelector("[name='card[title]']").value
|
|
628
|
-
appendOptimisticCard(generateULID(), title)
|
|
629
|
-
event.target.reset()
|
|
630
|
-
})
|
|
631
|
-
```
|
|
632
|
-
|
|
633
|
-
**Client-side ULID generation** for optimistic record IDs (time-ordered, collision-resistant):
|
|
634
|
-
|
|
635
|
-
```javascript
|
|
636
|
-
function generateULID() {
|
|
637
|
-
const time = Date.now().toString(36).padStart(10, "0")
|
|
638
|
-
const rand = Array.from(crypto.getRandomValues(new Uint8Array(10)))
|
|
639
|
-
.map((b) => b.toString(36).padStart(2, "0")).join("").slice(0, 16)
|
|
640
|
-
return (time + rand).toUpperCase()
|
|
641
|
-
}
|
|
642
|
-
```
|
|
643
|
-
|
|
644
|
-
**Reconciliation** — Turbo 8 diffs server-rendered DOM against client state. Matching optimistic elements are preserved; mismatches are corrected.
|
|
645
|
-
|
|
646
|
-
## 2.5 HTTP Response Conventions
|
|
647
|
-
|
|
648
|
-
- **422 Unprocessable Entity** — Validation failures. Turbo re-renders the form frame with errors.
|
|
649
|
-
- **303 See Other** — Successful submissions. Turbo follows redirect with GET.
|
|
650
|
-
- **Never return 200** for form submissions expecting a redirect.
|
|
651
|
-
- **Always use 303** (not 301/302) — guarantees GET follow-up, prevents resubmission.
|
|
652
|
-
|
|
653
|
-
```ruby
|
|
654
|
-
def update
|
|
655
|
-
if @card.update(card_params)
|
|
656
|
-
redirect_to @card, status: :see_other
|
|
657
|
-
else
|
|
658
|
-
render :edit, status: :unprocessable_entity
|
|
659
|
-
end
|
|
660
|
-
end
|
|
661
|
-
```
|
|
662
|
-
|
|
663
|
-
## 2.6 View Transitions
|
|
664
|
-
|
|
665
|
-
Use the **View Transitions API** with Turbo for animated page transitions. Enable globally:
|
|
666
|
-
|
|
667
|
-
```erb
|
|
668
|
-
<meta name="view-transition" content="same-origin">
|
|
669
|
-
```
|
|
670
|
-
|
|
671
|
-
```css
|
|
672
|
-
::view-transition-old(root) { animation: fade-out 150ms ease-in; }
|
|
673
|
-
::view-transition-new(root) { animation: fade-in 150ms ease-out; }
|
|
674
|
-
```
|
|
675
|
-
|
|
676
|
-
**Direction-aware transitions** — Capture direction in `turbo:click`, apply in `turbo:before-render`, clean up in `turbo:load`:
|
|
677
|
-
|
|
678
|
-
```javascript
|
|
679
|
-
let direction = "forward"
|
|
680
|
-
|
|
681
|
-
document.addEventListener("turbo:click", (event) => {
|
|
682
|
-
direction = event.target.closest("[data-direction]")?.dataset.direction || "forward"
|
|
683
|
-
})
|
|
684
|
-
|
|
685
|
-
document.addEventListener("turbo:before-render", (event) => {
|
|
686
|
-
if (document.documentElement.hasAttribute("data-turbo-preview")) return
|
|
687
|
-
document.documentElement.dataset.transitionDirection = direction
|
|
688
|
-
})
|
|
689
|
-
|
|
690
|
-
document.addEventListener("turbo:load", () => {
|
|
691
|
-
delete document.documentElement.dataset.transitionDirection
|
|
692
|
-
})
|
|
693
|
-
```
|
|
694
|
-
|
|
695
|
-
```css
|
|
696
|
-
[data-transition-direction="forward"]::view-transition-old(root) { animation: slide-out-left 200ms ease-in; }
|
|
697
|
-
[data-transition-direction="forward"]::view-transition-new(root) { animation: slide-in-right 200ms ease-out; }
|
|
698
|
-
[data-transition-direction="backward"]::view-transition-old(root) { animation: slide-out-right 200ms ease-in; }
|
|
699
|
-
[data-transition-direction="backward"]::view-transition-new(root) { animation: slide-in-left 200ms ease-out; }
|
|
700
|
-
```
|
|
701
|
-
|
|
702
|
-
### Summary
|
|
703
|
-
|
|
704
|
-
- **Turbo Drive events** — `turbo:before-cache` for cleanup, `turbo:before-render` for animations (pausable), guard against `data-turbo-preview`
|
|
705
|
-
- **Frame boundaries** match the smallest rerenderable unit; use `dom_id` for IDs
|
|
706
|
-
- **Lazy frames** defer with `loading="lazy"` + `src`; post-load setup via `turbo:frame-load`
|
|
707
|
-
- **Tabs and pagination** use `data-turbo-frame` + `data-turbo-action="advance"` for history
|
|
708
|
-
- **HTTP status codes** — 422 for errors, 303 for redirects, never 200 for redirect-expecting forms
|
|
709
|
-
- **Prefer built-in stream actions** before custom; register custom actions on `StreamActions`
|
|
710
|
-
- **Inline streams** execute on DOM insertion and self-remove; store in `<template>` to control timing
|
|
711
|
-
- **Optimistic UI** clones stream templates on `turbo:submit-start`, uses ULIDs, reconciles via morph
|
|
712
|
-
- **Broadcasts** use `after_create_commit` + `broadcast_append_to` / `broadcast_replace_to`
|
|
713
|
-
- **View Transitions** integrate through `turbo:before-render` with direction-aware CSS
|
|
714
|
-
|
|
715
|
-
---
|
|
716
|
-
|
|
717
|
-
# 3. Stimulus Controllers
|
|
718
|
-
|
|
719
|
-
## 3.1 Controller Architecture
|
|
720
|
-
|
|
721
|
-
**File naming** follows the Stimulus convention: `app/javascript/controllers/{name}_controller.js`. Multi-word names use kebab-case in filenames and camelCase in class names:
|
|
722
|
-
|
|
723
|
-
| File | Class | Identifier |
|
|
724
|
-
|------|-------|------------|
|
|
725
|
-
| `upload_preview_controller.js` | `UploadPreviewController` | `upload-preview` |
|
|
726
|
-
| `broadcast_channel_controller.js` | `BroadcastChannelController` | `broadcast-channel` |
|
|
727
|
-
| `media_player_controller.js` | `MediaPlayerController` | `media-player` |
|
|
728
|
-
|
|
729
|
-
**Registration** is automatic via `esbuild` or `importmap` conventions. Controllers placed in `app/javascript/controllers/` are auto-discovered and registered. No manual `application.register()` calls needed.
|
|
730
|
-
|
|
731
|
-
**Contract-first declaration** means every controller declares its full interface at the top, before any methods. This makes controllers self-documenting and lets agents understand the API without reading implementation:
|
|
732
|
-
|
|
733
|
-
```javascript
|
|
734
|
-
import { Controller } from "@hotwired/stimulus"
|
|
735
|
-
|
|
736
|
-
export default class extends Controller {
|
|
737
|
-
static values = { url: String, refreshInterval: Number, active: Boolean }
|
|
738
|
-
static targets = ["output", "spinner", "emptyState"]
|
|
739
|
-
static outlets = ["filter", "notification"]
|
|
740
|
-
static classes = ["loading", "hidden"]
|
|
741
|
-
|
|
742
|
-
// Lifecycle methods
|
|
743
|
-
connect() { }
|
|
744
|
-
disconnect() { }
|
|
745
|
-
|
|
746
|
-
// Action methods
|
|
747
|
-
refresh() { }
|
|
748
|
-
toggle() { }
|
|
749
|
-
}
|
|
750
|
-
```
|
|
751
|
-
|
|
752
|
-
**Single-purpose controllers.** Each controller owns one behavior. A `clipboard_controller.js` copies text. A `toggle_controller.js` shows/hides elements. Compose multiple controllers on the same element rather than building one large controller:
|
|
753
|
-
|
|
754
|
-
```html
|
|
755
|
-
<div data-controller="clipboard toggle tooltip"
|
|
756
|
-
data-clipboard-text-value="https://example.com/share/abc123"
|
|
757
|
-
data-toggle-class="hidden">
|
|
758
|
-
<button data-action="clipboard#copy toggle#toggle">Copy Link</button>
|
|
759
|
-
<span data-toggle-target="content" class="hidden">Copied!</span>
|
|
760
|
-
</div>
|
|
761
|
-
```
|
|
762
|
-
|
|
763
|
-
## 3.2 Lifecycle
|
|
764
|
-
|
|
765
|
-
**Symmetric setup and teardown.** Every resource acquired in `connect()` must be released in `disconnect()`. Turbo navigations and morphs trigger these repeatedly, so leaks accumulate fast:
|
|
766
|
-
|
|
767
|
-
```javascript
|
|
768
|
-
import { Controller } from "@hotwired/stimulus"
|
|
769
|
-
|
|
770
|
-
export default class extends Controller {
|
|
771
|
-
static values = { channel: String }
|
|
772
|
-
|
|
773
|
-
connect() {
|
|
774
|
-
this.broadcast = new BroadcastChannel(this.channelValue)
|
|
775
|
-
this.broadcast.onmessage = this.handleMessage.bind(this)
|
|
776
|
-
|
|
777
|
-
this.resizeObserver = new ResizeObserver(this.handleResize.bind(this))
|
|
778
|
-
this.resizeObserver.observe(this.element)
|
|
779
|
-
|
|
780
|
-
this.refreshTimer = setInterval(() => this.refresh(), 30000)
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
disconnect() {
|
|
784
|
-
this.broadcast.close()
|
|
785
|
-
|
|
786
|
-
this.resizeObserver.disconnect()
|
|
787
|
-
|
|
788
|
-
clearInterval(this.refreshTimer)
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
```
|
|
792
|
-
|
|
793
|
-
**Common teardown checklist:**
|
|
794
|
-
|
|
795
|
-
| Resource | Setup | Teardown |
|
|
796
|
-
|----------|-------|----------|
|
|
797
|
-
| BroadcastChannel | `new BroadcastChannel()` | `.close()` |
|
|
798
|
-
| Blob URL | `URL.createObjectURL()` | `URL.revokeObjectURL()` |
|
|
799
|
-
| Third-party player | `WaveSurfer.create()` | `.destroy()` |
|
|
800
|
-
| Timer | `setInterval()` / `setTimeout()` | `clearInterval()` / `clearTimeout()` |
|
|
801
|
-
| Observer | `.observe()` | `.disconnect()` |
|
|
802
|
-
| EventListener (window/document) | `addEventListener()` | `removeEventListener()` |
|
|
803
|
-
|
|
804
|
-
**Guard `valueChanged` callbacks.** Value callbacks can fire before `connect()` completes, which means targets or instance properties may not exist yet. Always guard:
|
|
805
|
-
|
|
806
|
-
```javascript
|
|
807
|
-
export default class extends Controller {
|
|
808
|
-
static values = { url: String }
|
|
809
|
-
static targets = ["frame"]
|
|
810
|
-
|
|
811
|
-
urlValueChanged(url) {
|
|
812
|
-
// Guard — target may not be connected yet
|
|
813
|
-
this.frameTarget?.src = url
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
```
|
|
817
|
-
|
|
818
|
-
Alternatively, use an early return:
|
|
819
|
-
|
|
820
|
-
```javascript
|
|
821
|
-
urlValueChanged(url) {
|
|
822
|
-
if (!this.hasFrameTarget) return
|
|
823
|
-
this.frameTarget.src = url
|
|
824
|
-
}
|
|
825
|
-
```
|
|
826
|
-
|
|
827
|
-
## 3.3 Values (Reactive State)
|
|
828
|
-
|
|
829
|
-
**Declaration** uses `static values` with type constructors. Stimulus handles serialization, type coercion, and default values:
|
|
830
|
-
|
|
831
|
-
```javascript
|
|
832
|
-
export default class extends Controller {
|
|
833
|
-
static values = {
|
|
834
|
-
url: String, // default: ""
|
|
835
|
-
count: Number, // default: 0
|
|
836
|
-
active: Boolean, // default: false
|
|
837
|
-
filters: Object, // default: {}
|
|
838
|
-
items: Array, // default: []
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
```
|
|
842
|
-
|
|
843
|
-
```html
|
|
844
|
-
<div data-controller="dashboard"
|
|
845
|
-
data-dashboard-url-value="/api/stats"
|
|
846
|
-
data-dashboard-count-value="42"
|
|
847
|
-
data-dashboard-active-value="true"
|
|
848
|
-
data-dashboard-filters-value='{"status":"open"}'>
|
|
849
|
-
</div>
|
|
850
|
-
```
|
|
851
|
-
|
|
852
|
-
**React with `{name}ValueChanged` callbacks.** These fire whenever the value changes, including the initial set from HTML attributes:
|
|
853
|
-
|
|
854
|
-
```javascript
|
|
855
|
-
export default class extends Controller {
|
|
856
|
-
static values = { page: Number }
|
|
857
|
-
static targets = ["list", "counter"]
|
|
858
|
-
|
|
859
|
-
pageValueChanged(current, previous) {
|
|
860
|
-
if (previous !== undefined) {
|
|
861
|
-
this.fetchPage(current)
|
|
862
|
-
}
|
|
863
|
-
this.counterTarget.textContent = `Page ${current}`
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
next() {
|
|
867
|
-
this.pageValue++ // Triggers pageValueChanged automatically
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
```
|
|
871
|
-
|
|
872
|
-
**Bridge third-party libraries through value callbacks.** The value becomes the single source of truth. The callback translates it into library-specific API calls:
|
|
873
|
-
|
|
874
|
-
```javascript
|
|
875
|
-
import { Controller } from "@hotwired/stimulus"
|
|
876
|
-
import Chart from "chart.js/auto"
|
|
877
|
-
|
|
878
|
-
export default class extends Controller {
|
|
879
|
-
static values = { type: String, data: Object, options: Object }
|
|
880
|
-
static targets = ["canvas"]
|
|
881
|
-
|
|
882
|
-
connect() {
|
|
883
|
-
this.chart = new Chart(this.canvasTarget, {
|
|
884
|
-
type: this.typeValue,
|
|
885
|
-
data: this.dataValue,
|
|
886
|
-
options: this.optionsValue
|
|
887
|
-
})
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
dataValueChanged(data) {
|
|
891
|
-
if (!this.chart) return
|
|
892
|
-
this.chart.data = data
|
|
893
|
-
this.chart.update()
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
optionsValueChanged(options) {
|
|
897
|
-
if (!this.chart) return
|
|
898
|
-
this.chart.options = options
|
|
899
|
-
this.chart.update()
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
disconnect() {
|
|
903
|
-
this.chart.destroy()
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
```
|
|
907
|
-
|
|
908
|
-
**Values as the single source of truth.** Never duplicate state in DOM attributes, instance variables, or dataset entries alongside values. If a controller needs state, declare a value. Read state from `this.{name}Value`, mutate via `this.{name}Value = x`, and react in the callback. This keeps the data flow unidirectional and predictable.
|
|
909
|
-
|
|
910
|
-
## 3.4 Targets (DOM References)
|
|
911
|
-
|
|
912
|
-
**Declaration** registers named references to child elements:
|
|
913
|
-
|
|
914
|
-
```javascript
|
|
915
|
-
export default class extends Controller {
|
|
916
|
-
static targets = ["input", "output", "submitButton"]
|
|
917
|
-
}
|
|
918
|
-
```
|
|
919
|
-
|
|
920
|
-
```html
|
|
921
|
-
<form data-controller="search">
|
|
922
|
-
<input data-search-target="input" type="text">
|
|
923
|
-
<div data-search-target="output"></div>
|
|
924
|
-
<button data-search-target="submitButton">Search</button>
|
|
925
|
-
</form>
|
|
926
|
-
```
|
|
927
|
-
|
|
928
|
-
Access via `this.inputTarget` (first match), `this.inputTargets` (all matches), and `this.hasInputTarget` (existence check).
|
|
929
|
-
|
|
930
|
-
**Target callbacks** fire when the DOM changes. These are essential for Turbo Stream integration — when elements are appended or removed, the controller reacts automatically:
|
|
931
|
-
|
|
932
|
-
```javascript
|
|
933
|
-
export default class extends Controller {
|
|
934
|
-
static targets = ["item", "counter", "emptyState"]
|
|
935
|
-
|
|
936
|
-
itemTargetConnected(element) {
|
|
937
|
-
this.updateCount()
|
|
938
|
-
element.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200 })
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
itemTargetDisconnected(element) {
|
|
942
|
-
this.updateCount()
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
updateCount() {
|
|
946
|
-
this.counterTarget.textContent = this.itemTargets.length
|
|
947
|
-
this.emptyStateTarget.hidden = this.itemTargets.length > 0
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
```
|
|
951
|
-
|
|
952
|
-
**Keep target callbacks idempotent.** Turbo morphs and reconnections can trigger `TargetConnected` multiple times for the same element. Avoid additive side effects:
|
|
953
|
-
|
|
954
|
-
```javascript
|
|
955
|
-
// Bad — adds duplicate listeners on reconnect
|
|
956
|
-
itemTargetConnected(element) {
|
|
957
|
-
element.addEventListener("click", this.handleClick)
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
// Good — safe across repeated connect/disconnect cycles
|
|
961
|
-
itemTargetConnected(element) {
|
|
962
|
-
element.handleClick ||= this.handleClick.bind(this)
|
|
963
|
-
element.removeEventListener("click", element.handleClick)
|
|
964
|
-
element.addEventListener("click", element.handleClick)
|
|
965
|
-
}
|
|
966
|
-
```
|
|
967
|
-
|
|
968
|
-
**Derive computed state from targets** rather than tracking counts or flags in separate values:
|
|
969
|
-
|
|
970
|
-
```javascript
|
|
971
|
-
get isEmpty() {
|
|
972
|
-
return this.itemTargets.length === 0
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
get selectedItems() {
|
|
976
|
-
return this.itemTargets.filter(el => el.dataset.selected === "true")
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
get selectedCount() {
|
|
980
|
-
return this.selectedItems.length
|
|
981
|
-
}
|
|
982
|
-
```
|
|
983
|
-
|
|
984
|
-
## 3.5 Outlets (Controller-to-Controller Communication)
|
|
985
|
-
|
|
986
|
-
**Outlets** let one controller access another controller's instance directly. Declare with `static outlets`:
|
|
987
|
-
|
|
988
|
-
```javascript
|
|
989
|
-
// dashboard_controller.js
|
|
990
|
-
export default class extends Controller {
|
|
991
|
-
static outlets = ["chart", "filter"]
|
|
992
|
-
|
|
993
|
-
apply() {
|
|
994
|
-
const filters = this.filterOutlet.currentFilters
|
|
995
|
-
this.chartOutlets.forEach(chart => chart.reload(filters))
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
```
|
|
999
|
-
|
|
1000
|
-
```html
|
|
1001
|
-
<div data-controller="dashboard"
|
|
1002
|
-
data-dashboard-chart-outlet=".chart-widget"
|
|
1003
|
-
data-dashboard-filter-outlet="#main-filter">
|
|
1004
|
-
|
|
1005
|
-
<div id="main-filter" data-controller="filter">...</div>
|
|
1006
|
-
<div class="chart-widget" data-controller="chart">...</div>
|
|
1007
|
-
<div class="chart-widget" data-controller="chart">...</div>
|
|
1008
|
-
</div>
|
|
1009
|
-
```
|
|
1010
|
-
|
|
1011
|
-
**Access patterns:**
|
|
1012
|
-
|
|
1013
|
-
| Accessor | Returns | Throws if missing |
|
|
1014
|
-
|----------|---------|-------------------|
|
|
1015
|
-
| `this.chartOutlet` | First matching controller | Yes |
|
|
1016
|
-
| `this.chartOutlets` | Array of all matching controllers | No (empty array) |
|
|
1017
|
-
| `this.hasChartOutlet` | Boolean | No |
|
|
1018
|
-
|
|
1019
|
-
**Outlet callbacks** notify when outlets connect or disconnect:
|
|
1020
|
-
|
|
1021
|
-
```javascript
|
|
1022
|
-
export default class extends Controller {
|
|
1023
|
-
static outlets = ["player"]
|
|
1024
|
-
|
|
1025
|
-
playerOutletConnected(controller, element) {
|
|
1026
|
-
controller.mute() // Direct method call on connected controller
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
playerOutletDisconnected(controller, element) {
|
|
1030
|
-
// Cleanup references
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
```
|
|
1034
|
-
|
|
1035
|
-
**Prefer outlets** over `this.application.getControllerForElementAndIdentifier()` or custom events for direct controller-to-controller communication. Outlets are declarative, observable, and automatically managed by Stimulus.
|
|
1036
|
-
|
|
1037
|
-
## 3.6 Actions & Parameters
|
|
1038
|
-
|
|
1039
|
-
**Action parameters** pass data from HTML to action methods without manual `dataset` parsing. Declare parameters with `data-{controller}-{param}-param`:
|
|
1040
|
-
|
|
1041
|
-
```html
|
|
1042
|
-
<div data-controller="cart">
|
|
1043
|
-
<button data-action="cart#add"
|
|
1044
|
-
data-cart-id-param="42"
|
|
1045
|
-
data-cart-name-param="Widget"
|
|
1046
|
-
data-cart-price-param="19.99">
|
|
1047
|
-
Add to Cart
|
|
1048
|
-
</button>
|
|
1049
|
-
</div>
|
|
1050
|
-
```
|
|
1051
|
-
|
|
1052
|
-
```javascript
|
|
1053
|
-
export default class extends Controller {
|
|
1054
|
-
add({ params: { id, name, price } }) {
|
|
1055
|
-
// id = 42 (Number), name = "Widget" (String), price = 19.99 (Number)
|
|
1056
|
-
// Types are automatically inferred from the value
|
|
1057
|
-
this.addItem(id, name, price)
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
```
|
|
1061
|
-
|
|
1062
|
-
**Keyboard filters** let you bind actions to specific key combinations:
|
|
1063
|
-
|
|
1064
|
-
```html
|
|
1065
|
-
<div data-controller="editor">
|
|
1066
|
-
<textarea data-action="keydown.ctrl+s->editor#save
|
|
1067
|
-
keydown.meta+s->editor#save
|
|
1068
|
-
keydown.esc->editor#cancel
|
|
1069
|
-
keydown.ctrl+enter->editor#submit">
|
|
1070
|
-
</textarea>
|
|
1071
|
-
</div>
|
|
1072
|
-
```
|
|
1073
|
-
|
|
1074
|
-
**Supported keyboard filters:**
|
|
1075
|
-
|
|
1076
|
-
| Category | Filters |
|
|
1077
|
-
|----------|---------|
|
|
1078
|
-
| Modifiers | `ctrl`, `alt`, `shift`, `meta` |
|
|
1079
|
-
| Navigation | `enter`, `tab`, `esc`, `space`, `up`, `down`, `left`, `right` |
|
|
1080
|
-
| Letters | `a` through `z` |
|
|
1081
|
-
| Numbers | `0` through `9` |
|
|
1082
|
-
| Combinations | `ctrl+s`, `shift+enter`, `meta+k`, `ctrl+shift+p` |
|
|
1083
|
-
|
|
1084
|
-
**Non-focusable elements** need `tabindex="0"` to receive keyboard events:
|
|
1085
|
-
|
|
1086
|
-
```html
|
|
1087
|
-
<div data-controller="shortcuts"
|
|
1088
|
-
data-action="keydown.ctrl+z->shortcuts#undo"
|
|
1089
|
-
tabindex="0">
|
|
1090
|
-
<!-- Content that needs keyboard shortcuts -->
|
|
1091
|
-
</div>
|
|
1092
|
-
```
|
|
1093
|
-
|
|
1094
|
-
## 3.7 Common Patterns
|
|
1095
|
-
|
|
1096
|
-
### Image Upload Preview
|
|
1097
|
-
|
|
1098
|
-
Use `URL.createObjectURL()` for instant client-side previews. Always revoke the URL after the image loads to free memory:
|
|
1099
|
-
|
|
1100
|
-
```javascript
|
|
1101
|
-
export default class extends Controller {
|
|
1102
|
-
static targets = ["input", "preview"]
|
|
1103
|
-
|
|
1104
|
-
preview() {
|
|
1105
|
-
const file = this.inputTarget.files[0]
|
|
1106
|
-
if (!file) return
|
|
1107
|
-
|
|
1108
|
-
const url = URL.createObjectURL(file)
|
|
1109
|
-
this.previewTarget.src = url
|
|
1110
|
-
this.previewTarget.onload = () => URL.revokeObjectURL(url)
|
|
1111
|
-
this.previewTarget.hidden = false
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1114
|
-
```
|
|
1115
|
-
|
|
1116
|
-
```html
|
|
1117
|
-
<div data-controller="upload-preview">
|
|
1118
|
-
<input type="file" accept="image/*"
|
|
1119
|
-
data-upload-preview-target="input"
|
|
1120
|
-
data-action="change->upload-preview#preview">
|
|
1121
|
-
<img data-upload-preview-target="preview" hidden>
|
|
1122
|
-
</div>
|
|
1123
|
-
```
|
|
1124
|
-
|
|
1125
|
-
### Inter-Tab Communication
|
|
1126
|
-
|
|
1127
|
-
**BroadcastChannel** API enables communication across browser tabs. Create in `connect()`, close in `disconnect()`, and scope channels by purpose:
|
|
1128
|
-
|
|
1129
|
-
```javascript
|
|
1130
|
-
export default class extends Controller {
|
|
1131
|
-
static values = { channel: { type: String, default: "notifications" } }
|
|
1132
|
-
|
|
1133
|
-
connect() {
|
|
1134
|
-
this.channel = new BroadcastChannel(this.channelValue)
|
|
1135
|
-
this.channel.onmessage = this.handleMessage.bind(this)
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
handleMessage({ data }) {
|
|
1139
|
-
if (data.type === "logout") {
|
|
1140
|
-
window.location.href = "/session/new"
|
|
1141
|
-
}
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
broadcast(type, payload = {}) {
|
|
1145
|
-
this.channel.postMessage({ type, ...payload })
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
disconnect() {
|
|
1149
|
-
this.channel.close()
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1152
|
-
```
|
|
1153
|
-
|
|
1154
|
-
### Intersection Observer
|
|
1155
|
-
|
|
1156
|
-
Use `stimulus-use` `useIntersection` for viewport-based behavior like lazy loading or picture-in-picture triggers:
|
|
1157
|
-
|
|
1158
|
-
```javascript
|
|
1159
|
-
import { Controller } from "@hotwired/stimulus"
|
|
1160
|
-
import { useIntersection } from "stimulus-use"
|
|
1161
|
-
|
|
1162
|
-
export default class extends Controller {
|
|
1163
|
-
static values = { loaded: Boolean }
|
|
1164
|
-
|
|
1165
|
-
connect() {
|
|
1166
|
-
useIntersection(this, { threshold: 0.25 })
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
appear() {
|
|
1170
|
-
if (this.loadedValue) return
|
|
1171
|
-
this.loadedValue = true
|
|
1172
|
-
this.element.src = this.element.dataset.lazySrc
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
```
|
|
1176
|
-
|
|
1177
|
-
### MutationObserver
|
|
1178
|
-
|
|
1179
|
-
Watch DOM attribute changes for reactivity. Useful for observing Turbo Frame `busy` attribute changes:
|
|
1180
|
-
|
|
1181
|
-
```javascript
|
|
1182
|
-
export default class extends Controller {
|
|
1183
|
-
static targets = ["frame", "spinner"]
|
|
1184
|
-
|
|
1185
|
-
connect() {
|
|
1186
|
-
this.observer = new MutationObserver(this.handleMutation.bind(this))
|
|
1187
|
-
this.observer.observe(this.frameTarget, { attributes: true, attributeFilter: ["busy"] })
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
handleMutation(mutations) {
|
|
1191
|
-
const isBusy = this.frameTarget.hasAttribute("busy")
|
|
1192
|
-
this.spinnerTarget.hidden = !isBusy
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
disconnect() {
|
|
1196
|
-
this.observer.disconnect()
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
```
|
|
1200
|
-
|
|
1201
|
-
### Feature Detection
|
|
1202
|
-
|
|
1203
|
-
Always check browser API availability before exposing functionality. Hide or disable UI that depends on unsupported APIs:
|
|
1204
|
-
|
|
1205
|
-
```javascript
|
|
1206
|
-
export default class extends Controller {
|
|
1207
|
-
static targets = ["pipButton", "shareButton"]
|
|
1208
|
-
static classes = ["unsupported"]
|
|
1209
|
-
|
|
1210
|
-
connect() {
|
|
1211
|
-
if (!document.pictureInPictureEnabled) {
|
|
1212
|
-
this.pipButtonTarget.hidden = true
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
if (!navigator.share) {
|
|
1216
|
-
this.shareButtonTarget.hidden = true
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
if (!("mediaSession" in navigator)) {
|
|
1220
|
-
this.element.classList.add(this.unsupportedClass)
|
|
1221
|
-
}
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
```
|
|
1225
|
-
|
|
1226
|
-
## Summary
|
|
1227
|
-
|
|
1228
|
-
Stimulus controller conventions:
|
|
1229
|
-
|
|
1230
|
-
- **Contract-first** — declare `static values`, `targets`, `outlets`, `classes` before any methods
|
|
1231
|
-
- **Single-purpose** — one behavior per controller, compose via multiple `data-controller` bindings
|
|
1232
|
-
- **Symmetric lifecycle** — every `connect()` setup has a matching `disconnect()` teardown
|
|
1233
|
-
- **Guard value callbacks** — use optional chaining or `hasTarget` checks since callbacks fire before `connect()`
|
|
1234
|
-
- **Values as single source of truth** — bridge third-party libraries through `{name}ValueChanged` callbacks
|
|
1235
|
-
- **Target callbacks for Turbo** — use `TargetConnected` / `TargetDisconnected` to react to DOM changes from Turbo Streams
|
|
1236
|
-
- **Outlets over events** — prefer declared outlets for direct controller communication
|
|
1237
|
-
- **Action parameters over dataset** — use `data-{controller}-{param}-param` to pass typed data to actions
|
|
1238
|
-
- **Feature detection** — check API availability before exposing UI that depends on browser capabilities
|
|
1239
|
-
- **Idempotent callbacks** — target and outlet callbacks must be safe across repeated connect/disconnect cycles
|
|
1240
|
-
|
|
1241
|
-
---
|
|
1242
|
-
|
|
1243
|
-
# 4. View Templates & Partials
|
|
1244
|
-
|
|
1245
|
-
## 4.1 ERB Conventions
|
|
1246
|
-
|
|
1247
|
-
**Templates are rendering surfaces, not logic containers.** Keep them thin by delegating decisions to presenters or model methods. A template should read like a layout blueprint: structure and data slots, nothing more.
|
|
1248
|
-
|
|
1249
|
-
**Rules:**
|
|
1250
|
-
- No conditionals deeper than one level in a template
|
|
1251
|
-
- No query calls (`where`, `find`, `count`) in ERB — use presenters
|
|
1252
|
-
- Use `content_for` to inject section-specific content into layouts
|
|
1253
|
-
- Prefer `tag.div` helpers inside presenters over inline ERB for complex HTML
|
|
1254
|
-
|
|
1255
|
-
### `content_for` for Section-Specific Content
|
|
1256
|
-
|
|
1257
|
-
Use `content_for` to push page-specific content into layout slots:
|
|
1258
|
-
|
|
1259
|
-
```erb
|
|
1260
|
-
<%# app/views/messages/show.html.erb %>
|
|
1261
|
-
<% content_for :title, @message.subject %>
|
|
1262
|
-
<% content_for :head do %>
|
|
1263
|
-
<%= javascript_include_tag "trix" %>
|
|
1264
|
-
<% end %>
|
|
1265
|
-
|
|
1266
|
-
<div class="message">
|
|
1267
|
-
<%= render partial: "message", locals: { message: @message } %>
|
|
1268
|
-
</div>
|
|
1269
|
-
```
|
|
1270
|
-
|
|
1271
|
-
```erb
|
|
1272
|
-
<%# app/views/layouts/application.html.erb %>
|
|
1273
|
-
<head>
|
|
1274
|
-
<title><%= content_for(:title) || "App" %></title>
|
|
1275
|
-
<%= yield :head %>
|
|
1276
|
-
</head>
|
|
1277
|
-
```
|
|
1278
|
-
|
|
1279
|
-
### Delegating Logic to Presenters
|
|
1280
|
-
|
|
1281
|
-
When a template starts accumulating conditionals, extract them:
|
|
1282
|
-
|
|
1283
|
-
```erb
|
|
1284
|
-
<%# Bad — logic in template %>
|
|
1285
|
-
<% if user.avatar.attached? && user.avatar.variable? %>
|
|
1286
|
-
<%= image_tag user.avatar.variant(resize_to_limit: [100, 100]) %>
|
|
1287
|
-
<% else %>
|
|
1288
|
-
<%= image_tag "default_avatar.png" %>
|
|
1289
|
-
<% end %>
|
|
1290
|
-
|
|
1291
|
-
<%# Good — delegate to presenter %>
|
|
1292
|
-
<%= presenter.avatar_tag %>
|
|
1293
|
-
```
|
|
1294
|
-
|
|
1295
|
-
## 4.2 Partial Extraction Rules
|
|
1296
|
-
|
|
1297
|
-
**Extract a partial when:** the same markup appears in 2+ templates, or you identify a clear UI component boundary (card, form group, nav item). A partial is the smallest reusable rendering unit.
|
|
1298
|
-
|
|
1299
|
-
### Naming and Organization
|
|
1300
|
-
|
|
1301
|
-
- Name with a leading underscore: `_card.html.erb`
|
|
1302
|
-
- Place cross-controller partials in `app/views/shared/`: `shared/_flash.html.erb`
|
|
1303
|
-
- Name partials after the UI concept, not the model: `_card.html.erb`, not `_message_display.html.erb`
|
|
1304
|
-
|
|
1305
|
-
### Pass Data Explicitly
|
|
1306
|
-
|
|
1307
|
-
**Never rely on instance variables inside partials.** Always pass data via `locals:`:
|
|
1308
|
-
|
|
1309
|
-
```erb
|
|
1310
|
-
<%# Good — explicit locals %>
|
|
1311
|
-
<%= render partial: "messages/card", locals: { message: message, show_actions: true } %>
|
|
1312
|
-
|
|
1313
|
-
<%# Also good — short form for collections %>
|
|
1314
|
-
<%= render partial: "messages/message", collection: @messages, as: :message %>
|
|
1315
|
-
|
|
1316
|
-
<%# Bad — instance variable dependency %>
|
|
1317
|
-
<%= render partial: "messages/card" %>
|
|
1318
|
-
<%# partial internally references @message — fragile and implicit %>
|
|
1319
|
-
```
|
|
1320
|
-
|
|
1321
|
-
### Cross-Controller Partials
|
|
1322
|
-
|
|
1323
|
-
For UI components shared across controllers, use the `shared/` directory:
|
|
1324
|
-
|
|
1325
|
-
```erb
|
|
1326
|
-
<%# From any controller %>
|
|
1327
|
-
<%= render partial: "shared/empty_state", locals: { message: "No results found", icon: "search" } %>
|
|
1328
|
-
<%= render partial: "shared/pagination", locals: { pagy: @pagy } %>
|
|
1329
|
-
```
|
|
1330
|
-
|
|
1331
|
-
## 4.3 Turbo Frame Wrapping in Views
|
|
1332
|
-
|
|
1333
|
-
**Wrap the rerenderable unit, not the entire page.** A Turbo Frame defines the boundary of what gets swapped on navigation or form submission. Frame IDs must match between the source page and the server response.
|
|
1334
|
-
|
|
1335
|
-
### Frame ID Conventions
|
|
1336
|
-
|
|
1337
|
-
Use `dom_id` for consistent, collision-free frame and target IDs:
|
|
1338
|
-
|
|
1339
|
-
```erb
|
|
1340
|
-
<%# app/views/messages/show.html.erb %>
|
|
1341
|
-
<%= turbo_frame_tag dom_id(message) do %>
|
|
1342
|
-
<h2><%= message.subject %></h2>
|
|
1343
|
-
<p><%= message.body %></p>
|
|
1344
|
-
<%= link_to "Edit", edit_message_path(message) %>
|
|
1345
|
-
<% end %>
|
|
1346
|
-
|
|
1347
|
-
<%# app/views/messages/edit.html.erb %>
|
|
1348
|
-
<%= turbo_frame_tag dom_id(message) do %>
|
|
1349
|
-
<%= render "form", message: message %>
|
|
1350
|
-
<% end %>
|
|
1351
|
-
```
|
|
1352
|
-
|
|
1353
|
-
The frame IDs match (`message_123`), so clicking "Edit" swaps only the frame content.
|
|
1354
|
-
|
|
1355
|
-
### Lazy-Loaded Frames
|
|
1356
|
-
|
|
1357
|
-
Use the `src` attribute to load frame content on demand:
|
|
1358
|
-
|
|
1359
|
-
```erb
|
|
1360
|
-
<%= turbo_frame_tag "comments", src: message_comments_path(message), loading: :lazy do %>
|
|
1361
|
-
<p>Loading comments...</p>
|
|
1362
|
-
<% end %>
|
|
1363
|
-
```
|
|
1364
|
-
|
|
1365
|
-
The frame renders placeholder content immediately, then fetches and replaces it when the frame enters the viewport.
|
|
1366
|
-
|
|
1367
|
-
## 4.4 Template-Based DOM Patterns
|
|
1368
|
-
|
|
1369
|
-
**Store Turbo Stream markup in `<template>` elements** to prevent premature execution by the browser. This is essential for optimistic UI patterns where you prepare stream actions in the DOM and dispatch them from Stimulus controllers.
|
|
1370
|
-
|
|
1371
|
-
### Clone-and-Append Pattern
|
|
1372
|
-
|
|
1373
|
-
```html
|
|
1374
|
-
<%# Embed a hidden template in the page %>
|
|
1375
|
-
<template data-optimistic-stream>
|
|
1376
|
-
<turbo-stream action="append" target="messages">
|
|
1377
|
-
<template>
|
|
1378
|
-
<div class="message message--pending" id="pending_message">
|
|
1379
|
-
<p data-placeholder>Sending...</p>
|
|
1380
|
-
</div>
|
|
1381
|
-
</template>
|
|
1382
|
-
</turbo-stream>
|
|
1383
|
-
</template>
|
|
1384
|
-
```
|
|
1385
|
-
|
|
1386
|
-
```javascript
|
|
1387
|
-
// app/javascript/controllers/optimistic_controller.js
|
|
1388
|
-
import { Controller } from "@hotwired/stimulus"
|
|
1389
|
-
|
|
1390
|
-
export default class extends Controller {
|
|
1391
|
-
static targets = ["template"]
|
|
1392
|
-
|
|
1393
|
-
submit() {
|
|
1394
|
-
const template = this.templateTarget.content.cloneNode(true)
|
|
1395
|
-
const id = `pending_${Date.now()}`
|
|
1396
|
-
template.querySelector(".message").id = id
|
|
1397
|
-
document.body.append(template)
|
|
1398
|
-
}
|
|
1399
|
-
}
|
|
1400
|
-
```
|
|
1401
|
-
|
|
1402
|
-
The `<template>` element prevents the `<turbo-stream>` from executing on page load. Cloning and appending triggers the stream action on demand.
|
|
1403
|
-
|
|
1404
|
-
## 4.5 Cache-Safe Views
|
|
1405
|
-
|
|
1406
|
-
**Turbo caches pages before navigating away.** If transient UI states (open dropdowns, flash messages, active modals) are cached, they reappear as stale artifacts on restoration visits. Clean them up before the cache snapshot.
|
|
1407
|
-
|
|
1408
|
-
### Cleaning Transient UI
|
|
1409
|
-
|
|
1410
|
-
```javascript
|
|
1411
|
-
// app/javascript/controllers/cache_cleanup_controller.js
|
|
1412
|
-
import { Controller } from "@hotwired/stimulus"
|
|
1413
|
-
|
|
1414
|
-
export default class extends Controller {
|
|
1415
|
-
connect() {
|
|
1416
|
-
document.addEventListener("turbo:before-cache", this.cleanup)
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
disconnect() {
|
|
1420
|
-
document.removeEventListener("turbo:before-cache", this.cleanup)
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
cleanup = () => {
|
|
1424
|
-
// Close dropdowns
|
|
1425
|
-
this.element.querySelectorAll("[data-expanded]").forEach(el => {
|
|
1426
|
-
el.removeAttribute("data-expanded")
|
|
1427
|
-
})
|
|
1428
|
-
// Clear flash messages
|
|
1429
|
-
this.element.querySelectorAll(".flash").forEach(el => el.remove())
|
|
1430
|
-
// Reset form states
|
|
1431
|
-
this.element.querySelectorAll("form").forEach(form => form.reset())
|
|
1432
|
-
}
|
|
1433
|
-
}
|
|
1434
|
-
```
|
|
1435
|
-
|
|
1436
|
-
### Guard Against Preview Rendering
|
|
1437
|
-
|
|
1438
|
-
When Turbo restores a cached page, it adds `data-turbo-preview` to the `<html>` element. Use this to guard rendering that depends on fresh data:
|
|
1439
|
-
|
|
1440
|
-
```erb
|
|
1441
|
-
<% unless request.headers["Purpose"] == "preview" %>
|
|
1442
|
-
<div class="live-metrics" data-controller="polling">
|
|
1443
|
-
<%= render partial: "dashboard/metrics", locals: { stats: @stats } %>
|
|
1444
|
-
</div>
|
|
1445
|
-
<% end %>
|
|
1446
|
-
```
|
|
1447
|
-
|
|
1448
|
-
### Fragment Caching with Presenter Keys
|
|
1449
|
-
|
|
1450
|
-
Use presenter cache keys to invalidate fragments when underlying data changes:
|
|
1451
|
-
|
|
1452
|
-
```erb
|
|
1453
|
-
<% cache presenter.cache_key do %>
|
|
1454
|
-
<div class="filtering-panel">
|
|
1455
|
-
<%= render partial: "filters/tags", locals: { tags: presenter.tags } %>
|
|
1456
|
-
<%= render partial: "filters/users", locals: { users: presenter.users } %>
|
|
1457
|
-
</div>
|
|
1458
|
-
<% end %>
|
|
1459
|
-
```
|
|
1460
|
-
|
|
1461
|
-
### Summary
|
|
1462
|
-
|
|
1463
|
-
View templates and partials:
|
|
1464
|
-
- **Keep templates thin** — delegate conditionals and queries to presenters
|
|
1465
|
-
- **Use `content_for`** to inject page-specific content into layouts
|
|
1466
|
-
- **Extract partials** when markup repeats or a clear component boundary exists
|
|
1467
|
-
- **Pass data via `locals:`** — never rely on instance variables in partials
|
|
1468
|
-
- **Use `dom_id`** for Turbo Frame IDs to ensure consistency between page and response
|
|
1469
|
-
- **Lazy-load frames** with `src` for deferred content
|
|
1470
|
-
- **Store Turbo Streams in `<template>` elements** to prevent premature execution
|
|
1471
|
-
- **Clean transient UI** in `turbo:before-cache` to avoid stale cached states
|
|
1472
|
-
- **Guard preview rendering** with `data-turbo-preview` checks
|
|
1473
|
-
- **Use presenter cache keys** for fragment caching invalidation
|
|
1474
|
-
|
|
1475
|
-
---
|
|
1476
|
-
|
|
1477
|
-
**Document Version**: 2.0
|
|
1478
|
-
**Last Updated**: 2026-02-17
|
|
1479
|
-
**Maintainer**: Development Team
|