source_monitor 0.2.0 → 0.3.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/.claude/agents/rails-concern.md +464 -0
- data/.claude/agents/rails-controller.md +424 -0
- data/.claude/agents/rails-hotwire.md +446 -0
- data/.claude/agents/rails-implement.md +374 -0
- data/.claude/agents/rails-job.md +334 -0
- data/.claude/agents/rails-lint.md +294 -0
- data/.claude/agents/rails-mailer.md +371 -0
- data/.claude/agents/rails-migration.md +449 -0
- data/.claude/agents/rails-model.md +420 -0
- data/.claude/agents/rails-policy.md +443 -0
- data/.claude/agents/rails-presenter.md +427 -0
- data/.claude/agents/rails-query.md +412 -0
- data/.claude/agents/rails-review.md +490 -0
- data/.claude/agents/rails-service.md +458 -0
- data/.claude/agents/rails-state-records.md +465 -0
- data/.claude/agents/rails-tdd.md +314 -0
- data/.claude/agents/rails-test.md +441 -0
- data/.claude/agents/rails-view-component.md +418 -0
- data/.claude/hooks/block-secrets.sh +52 -0
- data/.claude/settings.json +85 -0
- data/.claude/skills/action-cable-patterns/SKILL.md +296 -0
- data/.claude/skills/action-mailer-patterns/SKILL.md +295 -0
- data/.claude/skills/active-storage-setup/SKILL.md +311 -0
- data/.claude/skills/api-versioning/SKILL.md +294 -0
- data/.claude/skills/authentication-flow/SKILL.md +335 -0
- data/.claude/skills/authentication-flow/reference/current.md +248 -0
- data/.claude/skills/authentication-flow/reference/passwordless.md +253 -0
- data/.claude/skills/authentication-flow/reference/sessions.md +201 -0
- data/.claude/skills/authorization-pundit/SKILL.md +462 -0
- data/.claude/skills/caching-strategies/SKILL.md +350 -0
- data/.claude/skills/database-migrations/SKILL.md +354 -0
- data/.claude/skills/form-object-patterns/SKILL.md +399 -0
- data/.claude/skills/hotwire-patterns/SKILL.md +247 -0
- data/.claude/skills/hotwire-patterns/reference/stimulus.md +307 -0
- data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +112 -0
- data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +158 -0
- data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +218 -0
- data/.claude/skills/i18n-patterns/SKILL.md +320 -0
- data/.claude/skills/install/SKILL.md +367 -0
- data/.claude/skills/performance-optimization/SKILL.md +311 -0
- data/.claude/skills/rails-architecture/SKILL.md +259 -0
- data/.claude/skills/rails-architecture/reference/error-handling.md +333 -0
- data/.claude/skills/rails-architecture/reference/event-tracking.md +142 -0
- data/.claude/skills/rails-architecture/reference/layer-interactions.md +417 -0
- data/.claude/skills/rails-architecture/reference/multi-tenancy.md +152 -0
- data/.claude/skills/rails-architecture/reference/query-patterns.md +342 -0
- data/.claude/skills/rails-architecture/reference/service-patterns.md +286 -0
- data/.claude/skills/rails-architecture/reference/state-records.md +250 -0
- data/.claude/skills/rails-architecture/reference/testing-strategy.md +326 -0
- data/.claude/skills/rails-concern/SKILL.md +399 -0
- data/.claude/skills/rails-controller/SKILL.md +336 -0
- data/.claude/skills/rails-model-generator/SKILL.md +321 -0
- data/.claude/skills/rails-model-generator/reference/validations.md +298 -0
- data/.claude/skills/rails-presenter/SKILL.md +274 -0
- data/.claude/skills/rails-query-object/SKILL.md +289 -0
- data/.claude/skills/rails-service-object/SKILL.md +349 -0
- data/.claude/skills/solid-queue-setup/SKILL.md +307 -0
- data/.claude/skills/tdd-cycle/SKILL.md +359 -0
- data/.claude/skills/viewcomponent-patterns/SKILL.md +333 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +2 -0
- data/.ruby-version +1 -1
- data/.vbw-planning/.notification-log.jsonl +192 -0
- data/.vbw-planning/.session-log.jsonl +871 -0
- data/.vbw-planning/PROJECT.md +51 -0
- data/.vbw-planning/REQUIREMENTS.md +50 -0
- data/.vbw-planning/SHIPPED.md +28 -0
- data/.vbw-planning/codebase/ARCHITECTURE.md +147 -0
- data/.vbw-planning/codebase/CONCERNS.md +99 -0
- data/.vbw-planning/codebase/CONVENTIONS.md +97 -0
- data/.vbw-planning/codebase/DEPENDENCIES.md +100 -0
- data/.vbw-planning/codebase/INDEX.md +86 -0
- data/.vbw-planning/codebase/META.md +42 -0
- data/.vbw-planning/codebase/PATTERNS.md +262 -0
- data/.vbw-planning/codebase/STACK.md +101 -0
- data/.vbw-planning/codebase/STRUCTURE.md +324 -0
- data/.vbw-planning/codebase/TESTING.md +154 -0
- data/.vbw-planning/config.json +12 -0
- data/.vbw-planning/discovery.json +24 -0
- data/.vbw-planning/milestones/default/ROADMAP.md +115 -0
- data/.vbw-planning/milestones/default/STATE.md +83 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md +56 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md +187 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md +64 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md +137 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md +67 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md +142 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md +64 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md +138 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md +85 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md +147 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md +63 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md +129 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md +74 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md +154 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md +303 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md +510 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md +61 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md +161 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md +66 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md +132 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md +59 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md +171 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md +56 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md +152 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md +33 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md +42 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md +119 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md +52 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md +195 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md +79 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md +130 -0
- data/CHANGELOG.md +28 -0
- data/CLAUDE.md +179 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +114 -101
- data/Rakefile +2 -0
- data/app/assets/builds/source_monitor/application.css +2076 -0
- data/app/assets/builds/source_monitor/application.js +2758 -0
- data/app/assets/builds/source_monitor/application.js.map +7 -0
- data/app/controllers/source_monitor/application_controller.rb +2 -0
- data/app/controllers/source_monitor/health_controller.rb +2 -0
- data/app/controllers/source_monitor/import_sessions/bulk_configuration.rb +106 -0
- data/app/controllers/source_monitor/import_sessions/entry_annotation.rb +187 -0
- data/app/controllers/source_monitor/import_sessions/health_check_management.rb +112 -0
- data/app/controllers/source_monitor/import_sessions/opml_parser.rb +130 -0
- data/app/controllers/source_monitor/import_sessions_controller.rb +6 -507
- data/app/controllers/source_monitor/items_controller.rb +2 -0
- data/app/controllers/source_monitor/sources_controller.rb +0 -14
- data/app/helpers/source_monitor/application_helper.rb +4 -112
- data/app/helpers/source_monitor/health_badge_helper.rb +69 -0
- data/app/helpers/source_monitor/table_sort_helper.rb +53 -0
- data/app/jobs/source_monitor/application_job.rb +2 -0
- data/app/models/source_monitor/application_record.rb +2 -0
- data/app/models/source_monitor/log_entry.rb +0 -2
- data/config/coverage_baseline.json +217 -1862
- data/config/routes.rb +2 -0
- data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +2 -0
- data/db/migrate/20251014171659_add_performance_indexes.rb +2 -0
- data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +2 -0
- data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +2 -0
- data/db/migrate/20260210204022_add_composite_index_to_log_entries.rb +17 -0
- data/lib/source_monitor/assets/bundler.rb +2 -0
- data/lib/source_monitor/assets.rb +2 -0
- data/lib/source_monitor/configuration/authentication_settings.rb +62 -0
- data/lib/source_monitor/configuration/events.rb +60 -0
- data/lib/source_monitor/configuration/fetching_settings.rb +27 -0
- data/lib/source_monitor/configuration/health_settings.rb +27 -0
- data/lib/source_monitor/configuration/http_settings.rb +43 -0
- data/lib/source_monitor/configuration/model_definition.rb +108 -0
- data/lib/source_monitor/configuration/models.rb +36 -0
- data/lib/source_monitor/configuration/realtime_settings.rb +95 -0
- data/lib/source_monitor/configuration/retention_settings.rb +45 -0
- data/lib/source_monitor/configuration/scraper_registry.rb +67 -0
- data/lib/source_monitor/configuration/scraping_settings.rb +39 -0
- data/lib/source_monitor/configuration/validation_definition.rb +32 -0
- data/lib/source_monitor/configuration.rb +12 -579
- data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +138 -0
- data/lib/source_monitor/dashboard/queries/stats_query.rb +71 -0
- data/lib/source_monitor/dashboard/queries.rb +2 -195
- data/lib/source_monitor/engine.rb +2 -0
- data/lib/source_monitor/fetching/feed_fetcher/adaptive_interval.rb +141 -0
- data/lib/source_monitor/fetching/feed_fetcher/entry_processor.rb +89 -0
- data/lib/source_monitor/fetching/feed_fetcher/source_updater.rb +200 -0
- data/lib/source_monitor/fetching/feed_fetcher.rb +37 -379
- data/lib/source_monitor/items/item_creator/content_extractor.rb +113 -0
- data/lib/source_monitor/items/item_creator/entry_parser/media_extraction.rb +96 -0
- data/lib/source_monitor/items/item_creator/entry_parser.rb +294 -0
- data/lib/source_monitor/items/item_creator.rb +28 -455
- data/lib/source_monitor/setup/bundle_installer.rb +2 -0
- data/lib/source_monitor/setup/cli.rb +2 -0
- data/lib/source_monitor/setup/dependency_checker.rb +2 -0
- data/lib/source_monitor/setup/detectors.rb +2 -0
- data/lib/source_monitor/setup/gemfile_editor.rb +2 -0
- data/lib/source_monitor/setup/initializer_patcher.rb +2 -0
- data/lib/source_monitor/setup/install_generator.rb +2 -0
- data/lib/source_monitor/setup/migration_installer.rb +2 -0
- data/lib/source_monitor/setup/node_installer.rb +2 -0
- data/lib/source_monitor/setup/prompter.rb +2 -0
- data/lib/source_monitor/setup/requirements.rb +2 -0
- data/lib/source_monitor/setup/shell_runner.rb +2 -0
- data/lib/source_monitor/setup/verification/action_cable_verifier.rb +2 -0
- data/lib/source_monitor/setup/verification/printer.rb +2 -0
- data/lib/source_monitor/setup/verification/result.rb +2 -0
- data/lib/source_monitor/setup/verification/runner.rb +2 -0
- data/lib/source_monitor/setup/verification/solid_queue_verifier.rb +2 -0
- data/lib/source_monitor/setup/verification/telemetry_logger.rb +2 -0
- data/lib/source_monitor/setup/workflow.rb +2 -0
- data/lib/source_monitor/version.rb +3 -1
- data/lib/source_monitor.rb +140 -58
- data/lib/tasks/source_monitor_assets.rake +2 -0
- data/lib/tasks/source_monitor_setup.rake +2 -0
- data/lib/tasks/source_monitor_tasks.rake +2 -0
- data/source_monitor.gemspec +3 -1
- metadata +144 -4
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: form-object-patterns
|
|
3
|
+
description: Creates form objects for complex form handling with TDD. Use when building multi-model forms, search forms, wizard forms, or when user mentions form objects, complex forms, virtual models, or non-persisted forms.
|
|
4
|
+
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Form Object Patterns for Rails 8
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
Form objects encapsulate complex form logic:
|
|
12
|
+
- Multi-model forms (user + profile + address)
|
|
13
|
+
- Search/filter forms (non-persisted)
|
|
14
|
+
- Wizard/multi-step forms
|
|
15
|
+
- Virtual attributes with validation
|
|
16
|
+
- Decoupled from ActiveRecord models
|
|
17
|
+
|
|
18
|
+
## When to Use Form Objects
|
|
19
|
+
|
|
20
|
+
| Scenario | Use Form Object? |
|
|
21
|
+
|----------|-----------------|
|
|
22
|
+
| Single model CRUD | No (use model) |
|
|
23
|
+
| Multi-model creation | Yes |
|
|
24
|
+
| Complex validations across models | Yes |
|
|
25
|
+
| Search/filter forms | Yes |
|
|
26
|
+
| Wizard/multi-step forms | Yes |
|
|
27
|
+
| API params transformation | Yes |
|
|
28
|
+
| Contact forms (no persistence) | Yes |
|
|
29
|
+
|
|
30
|
+
## TDD Workflow
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
Form Object Progress:
|
|
34
|
+
- [ ] Step 1: Define form requirements
|
|
35
|
+
- [ ] Step 2: Write form object test (RED)
|
|
36
|
+
- [ ] Step 3: Run test (fails)
|
|
37
|
+
- [ ] Step 4: Create form object
|
|
38
|
+
- [ ] Step 5: Run test (GREEN)
|
|
39
|
+
- [ ] Step 6: Wire up controller
|
|
40
|
+
- [ ] Step 7: Create view form
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Base Form Class
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
# app/forms/application_form.rb
|
|
47
|
+
class ApplicationForm
|
|
48
|
+
include ActiveModel::Model
|
|
49
|
+
include ActiveModel::Attributes
|
|
50
|
+
include ActiveModel::Validations
|
|
51
|
+
|
|
52
|
+
def self.model_name
|
|
53
|
+
ActiveModel::Name.new(self, nil, name.chomp("Form"))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def persisted?
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def save
|
|
61
|
+
return false unless valid?
|
|
62
|
+
persist!
|
|
63
|
+
true
|
|
64
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
65
|
+
errors.add(:base, e.message)
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def persist!
|
|
72
|
+
raise NotImplementedError
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Pattern 1: Multi-Model Registration Form
|
|
78
|
+
|
|
79
|
+
### Test First (RED)
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
# test/forms/registration_form_test.rb
|
|
83
|
+
require "test_helper"
|
|
84
|
+
|
|
85
|
+
class RegistrationFormTest < ActiveSupport::TestCase
|
|
86
|
+
test "validates presence of email" do
|
|
87
|
+
form = RegistrationForm.new(email: "")
|
|
88
|
+
assert_not form.valid?
|
|
89
|
+
assert_includes form.errors[:email], "can't be blank"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
test "validates presence of password" do
|
|
93
|
+
form = RegistrationForm.new(password: "")
|
|
94
|
+
assert_not form.valid?
|
|
95
|
+
assert_includes form.errors[:password], "can't be blank"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
test "validates password minimum length" do
|
|
99
|
+
form = RegistrationForm.new(password: "short")
|
|
100
|
+
assert_not form.valid?
|
|
101
|
+
assert form.errors[:password].any? { |e| e.include?("too short") }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
test "#save with valid params returns true" do
|
|
105
|
+
form = RegistrationForm.new(
|
|
106
|
+
email: "user@example.com",
|
|
107
|
+
password: "password123",
|
|
108
|
+
password_confirmation: "password123",
|
|
109
|
+
company_name: "Acme Inc"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
assert form.save
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
test "#save creates a user" do
|
|
116
|
+
form = RegistrationForm.new(
|
|
117
|
+
email: "new-user@example.com",
|
|
118
|
+
password: "password123",
|
|
119
|
+
password_confirmation: "password123",
|
|
120
|
+
company_name: "Acme Inc"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
assert_difference("User.count", 1) { form.save }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
test "#save creates an account" do
|
|
127
|
+
form = RegistrationForm.new(
|
|
128
|
+
email: "new-account@example.com",
|
|
129
|
+
password: "password123",
|
|
130
|
+
password_confirmation: "password123",
|
|
131
|
+
company_name: "Acme Inc"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
assert_difference("Account.count", 1) { form.save }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
test "#save associates user with account" do
|
|
138
|
+
form = RegistrationForm.new(
|
|
139
|
+
email: "assoc@example.com",
|
|
140
|
+
password: "password123",
|
|
141
|
+
password_confirmation: "password123",
|
|
142
|
+
company_name: "Acme Inc"
|
|
143
|
+
)
|
|
144
|
+
form.save
|
|
145
|
+
assert_equal form.user.account, form.account
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
test "#save with invalid params returns false" do
|
|
149
|
+
form = RegistrationForm.new(email: "", password: "short")
|
|
150
|
+
assert_not form.save
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
test "#save with invalid params does not create records" do
|
|
154
|
+
form = RegistrationForm.new(email: "", password: "short")
|
|
155
|
+
assert_no_difference("User.count") { form.save }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
test "#save with duplicate email returns false" do
|
|
159
|
+
existing = users(:one)
|
|
160
|
+
form = RegistrationForm.new(
|
|
161
|
+
email: existing.email_address,
|
|
162
|
+
password: "password123",
|
|
163
|
+
password_confirmation: "password123",
|
|
164
|
+
company_name: "Acme Inc"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
assert_not form.save
|
|
168
|
+
assert_includes form.errors[:email], "has already been taken"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Implementation (GREEN)
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
# app/forms/registration_form.rb
|
|
177
|
+
class RegistrationForm < ApplicationForm
|
|
178
|
+
attribute :email, :string
|
|
179
|
+
attribute :password, :string
|
|
180
|
+
attribute :password_confirmation, :string
|
|
181
|
+
attribute :company_name, :string
|
|
182
|
+
attribute :phone, :string
|
|
183
|
+
|
|
184
|
+
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
185
|
+
validates :password, presence: true, length: { minimum: 8 }
|
|
186
|
+
validates :password_confirmation, presence: true
|
|
187
|
+
validates :company_name, presence: true
|
|
188
|
+
validate :passwords_match
|
|
189
|
+
validate :email_unique
|
|
190
|
+
|
|
191
|
+
attr_reader :user, :account
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
def persist!
|
|
196
|
+
ActiveRecord::Base.transaction do
|
|
197
|
+
@account = Account.create!(name: company_name)
|
|
198
|
+
@user = User.create!(
|
|
199
|
+
email_address: email,
|
|
200
|
+
password: password,
|
|
201
|
+
account: @account,
|
|
202
|
+
phone: phone
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def passwords_match
|
|
208
|
+
return if password == password_confirmation
|
|
209
|
+
errors.add(:password_confirmation, "doesn't match password")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def email_unique
|
|
213
|
+
return unless User.exists?(email_address: email&.downcase)
|
|
214
|
+
errors.add(:email, "has already been taken")
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Pattern 2: Search/Filter Form
|
|
220
|
+
|
|
221
|
+
### Test First
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
# test/forms/event_search_form_test.rb
|
|
225
|
+
require "test_helper"
|
|
226
|
+
|
|
227
|
+
class EventSearchFormTest < ActiveSupport::TestCase
|
|
228
|
+
setup do
|
|
229
|
+
@account = accounts(:one)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
test "#results returns all account events without filters" do
|
|
233
|
+
form = EventSearchForm.new(account: @account, params: {})
|
|
234
|
+
results = form.results
|
|
235
|
+
|
|
236
|
+
results.each do |event|
|
|
237
|
+
assert_equal @account.id, event.account_id
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
test "#results excludes other account events" do
|
|
242
|
+
form = EventSearchForm.new(account: @account, params: {})
|
|
243
|
+
other_event = events(:other_account)
|
|
244
|
+
|
|
245
|
+
assert_not_includes form.results, other_event
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
test "#results filters by event_type" do
|
|
249
|
+
form = EventSearchForm.new(account: @account, params: { event_type: "wedding" })
|
|
250
|
+
form.results.each do |event|
|
|
251
|
+
assert_equal "wedding", event.event_type
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
test "#any_filters? returns true with filters" do
|
|
256
|
+
form = EventSearchForm.new(account: @account, params: { query: "test" })
|
|
257
|
+
assert form.any_filters?
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
test "#any_filters? returns false without filters" do
|
|
261
|
+
form = EventSearchForm.new(account: @account, params: {})
|
|
262
|
+
assert_not form.any_filters?
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Implementation
|
|
268
|
+
|
|
269
|
+
```ruby
|
|
270
|
+
# app/forms/event_search_form.rb
|
|
271
|
+
class EventSearchForm < ApplicationForm
|
|
272
|
+
attribute :query, :string
|
|
273
|
+
attribute :event_type, :string
|
|
274
|
+
attribute :status, :string
|
|
275
|
+
attribute :start_date, :date
|
|
276
|
+
attribute :end_date, :date
|
|
277
|
+
|
|
278
|
+
attr_reader :account
|
|
279
|
+
|
|
280
|
+
def initialize(account:, params: {})
|
|
281
|
+
@account = account
|
|
282
|
+
super(params)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def results
|
|
286
|
+
scope = account.events
|
|
287
|
+
scope = apply_search(scope)
|
|
288
|
+
scope = apply_type_filter(scope)
|
|
289
|
+
scope = apply_status_filter(scope)
|
|
290
|
+
scope = apply_date_filter(scope)
|
|
291
|
+
scope.order(event_date: :desc)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def any_filters?
|
|
295
|
+
[query, event_type, status, start_date, end_date].any?(&:present?)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
private
|
|
299
|
+
|
|
300
|
+
def apply_search(scope)
|
|
301
|
+
return scope if query.blank?
|
|
302
|
+
scope.where("name LIKE :q OR description LIKE :q", q: "%#{sanitize_like(query)}%")
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def apply_type_filter(scope)
|
|
306
|
+
return scope if event_type.blank?
|
|
307
|
+
scope.where(event_type: event_type)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def apply_status_filter(scope)
|
|
311
|
+
return scope if status.blank?
|
|
312
|
+
scope.where(status: status)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def apply_date_filter(scope)
|
|
316
|
+
scope = scope.where("event_date >= ?", start_date) if start_date.present?
|
|
317
|
+
scope = scope.where("event_date <= ?", end_date) if end_date.present?
|
|
318
|
+
scope
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def sanitize_like(term)
|
|
322
|
+
term.gsub(/[%_]/) { |x| "\\#{x}" }
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## Pattern 3: Wizard/Multi-Step Form
|
|
328
|
+
|
|
329
|
+
```ruby
|
|
330
|
+
# app/forms/wizard/base_form.rb
|
|
331
|
+
module Wizard
|
|
332
|
+
class BaseForm < ApplicationForm
|
|
333
|
+
def self.steps
|
|
334
|
+
raise NotImplementedError
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def current_step
|
|
338
|
+
raise NotImplementedError
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def first_step?
|
|
342
|
+
current_step == self.class.steps.first
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def last_step?
|
|
346
|
+
current_step == self.class.steps.last
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def progress_percentage
|
|
350
|
+
steps = self.class.steps
|
|
351
|
+
((steps.index(current_step) + 1).to_f / steps.size * 100).round
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
## Controller Integration
|
|
358
|
+
|
|
359
|
+
```ruby
|
|
360
|
+
class RegistrationsController < ApplicationController
|
|
361
|
+
allow_unauthenticated_access
|
|
362
|
+
|
|
363
|
+
def new
|
|
364
|
+
@form = RegistrationForm.new
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def create
|
|
368
|
+
@form = RegistrationForm.new(registration_params)
|
|
369
|
+
|
|
370
|
+
if @form.save
|
|
371
|
+
start_new_session_for(@form.user)
|
|
372
|
+
redirect_to dashboard_path, notice: t(".success")
|
|
373
|
+
else
|
|
374
|
+
render :new, status: :unprocessable_entity
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
private
|
|
379
|
+
|
|
380
|
+
def registration_params
|
|
381
|
+
params.require(:registration).permit(
|
|
382
|
+
:email, :password, :password_confirmation,
|
|
383
|
+
:company_name, :phone
|
|
384
|
+
)
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
## Checklist
|
|
390
|
+
|
|
391
|
+
- [ ] Test written first (RED)
|
|
392
|
+
- [ ] Extends `ApplicationForm` or includes `ActiveModel::Model`
|
|
393
|
+
- [ ] Attributes declared with types
|
|
394
|
+
- [ ] Validations defined
|
|
395
|
+
- [ ] `#save` method with transaction (if multi-model)
|
|
396
|
+
- [ ] Controller uses form object
|
|
397
|
+
- [ ] View uses `form_with model: @form`
|
|
398
|
+
- [ ] Error handling in place
|
|
399
|
+
- [ ] All tests GREEN
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hotwire-patterns
|
|
3
|
+
description: Implements Hotwire patterns with Turbo Frames, Turbo Streams, and Stimulus controllers. Use when building interactive UIs, real-time updates, form handling, partial page updates, or when user mentions Turbo, Stimulus, or Hotwire.
|
|
4
|
+
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Hotwire Patterns for Rails 8
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
Hotwire = HTML Over The Wire. Build modern web apps without writing much JavaScript.
|
|
12
|
+
|
|
13
|
+
| Component | Purpose | Use Case |
|
|
14
|
+
|-----------|---------|----------|
|
|
15
|
+
| **Turbo Drive** | SPA-like navigation | Automatic, no code needed |
|
|
16
|
+
| **Turbo Frames** | Partial page updates | Inline editing, tabbed content |
|
|
17
|
+
| **Turbo Streams** | Real-time DOM updates | Live updates, flash messages |
|
|
18
|
+
| **Stimulus** | JavaScript sprinkles | Toggles, forms, interactions |
|
|
19
|
+
|
|
20
|
+
## When to Use Each Pattern
|
|
21
|
+
|
|
22
|
+
| Scenario | Pattern |
|
|
23
|
+
|----------|---------|
|
|
24
|
+
| Inline edit | Turbo Frame |
|
|
25
|
+
| Form submission with multiple updates | Turbo Stream |
|
|
26
|
+
| Real-time feed | Turbo Stream + ActionCable |
|
|
27
|
+
| Toggle visibility | Stimulus |
|
|
28
|
+
| Form validation | Stimulus |
|
|
29
|
+
| Infinite scroll | Turbo Frame + lazy loading |
|
|
30
|
+
| Modal dialogs | Turbo Frame |
|
|
31
|
+
| Flash messages | Turbo Stream |
|
|
32
|
+
|
|
33
|
+
## References
|
|
34
|
+
|
|
35
|
+
- See [turbo-frames.md](reference/turbo-frames.md) for frame patterns
|
|
36
|
+
- See [turbo-streams.md](reference/turbo-streams.md) for stream patterns
|
|
37
|
+
- See [stimulus.md](reference/stimulus.md) for controller patterns
|
|
38
|
+
- See [tailwind-integration.md](reference/tailwind-integration.md) for styling
|
|
39
|
+
|
|
40
|
+
## Turbo Frames
|
|
41
|
+
|
|
42
|
+
### Basic Frame
|
|
43
|
+
|
|
44
|
+
```erb
|
|
45
|
+
<%# app/views/posts/index.html.erb %>
|
|
46
|
+
<%= turbo_frame_tag "posts" do %>
|
|
47
|
+
<%= render @posts %>
|
|
48
|
+
<%= link_to "Load More", posts_path(page: 2) %>
|
|
49
|
+
<% end %>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Inline Editing
|
|
53
|
+
|
|
54
|
+
```erb
|
|
55
|
+
<%# _post.html.erb %>
|
|
56
|
+
<%= turbo_frame_tag dom_id(post) do %>
|
|
57
|
+
<article>
|
|
58
|
+
<h2><%= post.title %></h2>
|
|
59
|
+
<%= link_to "Edit", edit_post_path(post) %>
|
|
60
|
+
</article>
|
|
61
|
+
<% end %>
|
|
62
|
+
|
|
63
|
+
<%# edit.html.erb %>
|
|
64
|
+
<%= turbo_frame_tag dom_id(@post) do %>
|
|
65
|
+
<%= form_with model: @post do |f| %>
|
|
66
|
+
<%= f.text_field :title %>
|
|
67
|
+
<%= f.submit "Save" %>
|
|
68
|
+
<%= link_to "Cancel", @post %>
|
|
69
|
+
<% end %>
|
|
70
|
+
<% end %>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Lazy Loading
|
|
74
|
+
|
|
75
|
+
```erb
|
|
76
|
+
<%= turbo_frame_tag "comments", src: post_comments_path(@post), loading: :lazy do %>
|
|
77
|
+
<p>Loading comments...</p>
|
|
78
|
+
<% end %>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Turbo Streams
|
|
82
|
+
|
|
83
|
+
### From Controller
|
|
84
|
+
|
|
85
|
+
```erb
|
|
86
|
+
<%# app/views/posts/create.turbo_stream.erb %>
|
|
87
|
+
<%= turbo_stream.prepend "posts", @post %>
|
|
88
|
+
<%= turbo_stream.update "flash", partial: "shared/flash" %>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Stream Actions
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
turbo_stream.append "posts", @post # Add to end
|
|
95
|
+
turbo_stream.prepend "posts", @post # Add to start
|
|
96
|
+
turbo_stream.replace dom_id(@post), @post # Replace element
|
|
97
|
+
turbo_stream.update dom_id(@post), @post # Replace inner HTML
|
|
98
|
+
turbo_stream.remove dom_id(@post) # Remove element
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Flash Messages with Streams
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
# app/controllers/application_controller.rb
|
|
105
|
+
class ApplicationController < ActionController::Base
|
|
106
|
+
after_action :flash_to_turbo_stream, if: -> { request.format.turbo_stream? }
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def flash_to_turbo_stream
|
|
111
|
+
flash.each do |type, message|
|
|
112
|
+
flash.now[type] = message
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Stimulus Controllers
|
|
119
|
+
|
|
120
|
+
### Basic Controller
|
|
121
|
+
|
|
122
|
+
```javascript
|
|
123
|
+
// app/javascript/controllers/toggle_controller.js
|
|
124
|
+
import { Controller } from "@hotwired/stimulus"
|
|
125
|
+
|
|
126
|
+
export default class extends Controller {
|
|
127
|
+
static targets = ["content"]
|
|
128
|
+
|
|
129
|
+
toggle() {
|
|
130
|
+
this.contentTarget.classList.toggle("hidden")
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```erb
|
|
136
|
+
<div data-controller="toggle">
|
|
137
|
+
<button data-action="toggle#toggle">Toggle</button>
|
|
138
|
+
<div data-toggle-target="content">Hidden content</div>
|
|
139
|
+
</div>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Form Controller
|
|
143
|
+
|
|
144
|
+
```javascript
|
|
145
|
+
// app/javascript/controllers/form_controller.js
|
|
146
|
+
import { Controller } from "@hotwired/stimulus"
|
|
147
|
+
|
|
148
|
+
export default class extends Controller {
|
|
149
|
+
static targets = ["submit"]
|
|
150
|
+
|
|
151
|
+
enableSubmit() {
|
|
152
|
+
this.submitTarget.disabled = false
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
disableSubmit() {
|
|
156
|
+
this.submitTarget.disabled = true
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Testing Hotwire
|
|
162
|
+
|
|
163
|
+
### Turbo Stream Response Tests
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
# test/controllers/posts_controller_test.rb
|
|
167
|
+
require "test_helper"
|
|
168
|
+
|
|
169
|
+
class PostsControllerTest < ActionDispatch::IntegrationTest
|
|
170
|
+
setup do
|
|
171
|
+
sign_in users(:one)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
test "create returns turbo stream response" do
|
|
175
|
+
post posts_path,
|
|
176
|
+
params: { post: { title: "Test" } },
|
|
177
|
+
headers: { "Accept" => "text/vnd.turbo-stream.html" }
|
|
178
|
+
|
|
179
|
+
assert_response :success
|
|
180
|
+
assert_equal "text/vnd.turbo-stream.html", response.media_type
|
|
181
|
+
assert_includes response.body, "turbo-stream"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
test "create with HTML format redirects" do
|
|
185
|
+
post posts_path, params: { post: { title: "Test" } }
|
|
186
|
+
|
|
187
|
+
assert_redirected_to post_path(Post.last)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### System Tests (with JavaScript)
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
# test/system/posts_test.rb
|
|
196
|
+
require "application_system_test_case"
|
|
197
|
+
|
|
198
|
+
class PostsSystemTest < ApplicationSystemTestCase
|
|
199
|
+
setup do
|
|
200
|
+
@user = users(:one)
|
|
201
|
+
sign_in @user
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
test "updates post inline with Turbo Frame" do
|
|
205
|
+
post = posts(:one)
|
|
206
|
+
|
|
207
|
+
visit posts_path
|
|
208
|
+
within("#post_#{post.id}") do
|
|
209
|
+
click_link "Edit"
|
|
210
|
+
fill_in "Title", with: "Updated"
|
|
211
|
+
click_button "Save"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
assert_text "Updated"
|
|
215
|
+
assert_no_text post.title
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
test "adds comment with Turbo Stream" do
|
|
219
|
+
post = posts(:one)
|
|
220
|
+
|
|
221
|
+
visit post_path(post)
|
|
222
|
+
fill_in "Comment", with: "Great post!"
|
|
223
|
+
click_button "Add Comment"
|
|
224
|
+
|
|
225
|
+
within("#comments") do
|
|
226
|
+
assert_text "Great post!"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Debugging Tips
|
|
233
|
+
|
|
234
|
+
1. **Frame not updating?** Check frame IDs match exactly
|
|
235
|
+
2. **Stream not working?** Verify `Accept` header includes turbo-stream
|
|
236
|
+
3. **Stimulus not firing?** Check controller name matches file name
|
|
237
|
+
4. **Events not working?** Use `data-action="event->controller#method"`
|
|
238
|
+
|
|
239
|
+
## Checklist
|
|
240
|
+
|
|
241
|
+
- [ ] Identify update scope (full page vs partial)
|
|
242
|
+
- [ ] Choose pattern (Frame vs Stream vs Stimulus)
|
|
243
|
+
- [ ] Implement server response
|
|
244
|
+
- [ ] Add client-side markup
|
|
245
|
+
- [ ] Test with and without JavaScript
|
|
246
|
+
- [ ] Write system test for interactive behavior
|
|
247
|
+
- [ ] All tests GREEN
|