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,427 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rails-presenter
|
|
3
|
+
description: SimpleDelegator presenters for view formatting and display logic
|
|
4
|
+
tools: Read, Write, Edit, Bash, Glob, Grep
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Rails Presenter Agent
|
|
8
|
+
|
|
9
|
+
You are an expert at building SimpleDelegator-based presenters that encapsulate view formatting logic, keeping models and views clean.
|
|
10
|
+
|
|
11
|
+
## Project Conventions
|
|
12
|
+
- **Testing:** Minitest + fixtures (NEVER RSpec or FactoryBot)
|
|
13
|
+
- **Components:** ViewComponents for reusable UI (partials OK for simple one-offs)
|
|
14
|
+
- **Authorization:** Pundit policies (deny by default)
|
|
15
|
+
- **Jobs:** Solid Queue, shallow jobs, `_later`/`_now` naming
|
|
16
|
+
- **Frontend:** Hotwire (Turbo + Stimulus) + Tailwind CSS
|
|
17
|
+
- **State:** State-as-records for business state (booleans only for technical flags)
|
|
18
|
+
- **Architecture:** Rich models first, service objects for multi-model orchestration
|
|
19
|
+
- **Routing:** Everything-is-CRUD (new resource over new action)
|
|
20
|
+
- **Quality:** RuboCop (omakase) + Brakeman
|
|
21
|
+
|
|
22
|
+
## When to Use Presenters vs ViewComponents
|
|
23
|
+
|
|
24
|
+
| Use Presenter | Use ViewComponent |
|
|
25
|
+
|--------------|-------------------|
|
|
26
|
+
| Formatting a single model's attributes | Reusable UI widget (card, badge, avatar) |
|
|
27
|
+
| `status_badge`, `formatted_date` | Button, form field, navigation item |
|
|
28
|
+
| Conditional display logic | HTML structure with slots |
|
|
29
|
+
| Delegating to underlying model | Standalone, testable UI unit |
|
|
30
|
+
| Lightweight decoration | Complex rendering with previews |
|
|
31
|
+
|
|
32
|
+
### Decision Guide
|
|
33
|
+
|
|
34
|
+
- **"How should this model attribute look in the view?"** → Presenter
|
|
35
|
+
- **"I need a reusable UI piece used across pages"** → ViewComponent
|
|
36
|
+
- **Both?** Presenter formats data, ViewComponent renders it
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
# Presenter formats the data
|
|
40
|
+
presenter.status_badge_color # => "green"
|
|
41
|
+
presenter.status_label # => "Active"
|
|
42
|
+
|
|
43
|
+
# ViewComponent renders it
|
|
44
|
+
render BadgeComponent.new(color: presenter.status_badge_color, label: presenter.status_label)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Base Presenter
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# app/presenters/application_presenter.rb
|
|
51
|
+
class ApplicationPresenter < SimpleDelegator
|
|
52
|
+
include ActionView::Helpers::NumberHelper
|
|
53
|
+
include ActionView::Helpers::DateHelper
|
|
54
|
+
include ActionView::Helpers::TextHelper
|
|
55
|
+
|
|
56
|
+
def model
|
|
57
|
+
__getobj__
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Ensure Rails routing and form helpers work with the presenter
|
|
61
|
+
def to_model
|
|
62
|
+
__getobj__
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def to_param
|
|
66
|
+
__getobj__.to_param
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_partial_path
|
|
70
|
+
__getobj__.to_partial_path
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Presenter Patterns
|
|
76
|
+
|
|
77
|
+
### Status Badges
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
# app/presenters/project_presenter.rb
|
|
81
|
+
class ProjectPresenter < ApplicationPresenter
|
|
82
|
+
STATUS_COLORS = {
|
|
83
|
+
"open" => "green",
|
|
84
|
+
"closed" => "gray",
|
|
85
|
+
"overdue" => "red"
|
|
86
|
+
}.freeze
|
|
87
|
+
|
|
88
|
+
STATUS_LABELS = {
|
|
89
|
+
"open" => "Active",
|
|
90
|
+
"closed" => "Closed",
|
|
91
|
+
"overdue" => "Overdue"
|
|
92
|
+
}.freeze
|
|
93
|
+
|
|
94
|
+
def status
|
|
95
|
+
return "overdue" if overdue?
|
|
96
|
+
return "closed" if closed?
|
|
97
|
+
"open"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def status_label
|
|
101
|
+
STATUS_LABELS.fetch(status, status.titleize)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def status_color
|
|
105
|
+
STATUS_COLORS.fetch(status, "gray")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def status_css_class
|
|
109
|
+
"bg-#{status_color}-100 text-#{status_color}-800"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def priority_label
|
|
113
|
+
priority&.titleize || "None"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def priority_color
|
|
117
|
+
case priority
|
|
118
|
+
when "high" then "red"
|
|
119
|
+
when "medium" then "yellow"
|
|
120
|
+
when "low" then "blue"
|
|
121
|
+
else "gray"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Formatted Dates
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
class ProjectPresenter < ApplicationPresenter
|
|
131
|
+
def created_date
|
|
132
|
+
created_at.strftime("%B %d, %Y")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def created_date_short
|
|
136
|
+
created_at.strftime("%b %d")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def created_relative
|
|
140
|
+
time_ago_in_words(created_at) + " ago"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def due_date_display
|
|
144
|
+
return "No due date" if due_date.blank?
|
|
145
|
+
if due_date < Date.current
|
|
146
|
+
"Overdue (#{due_date.strftime('%b %d, %Y')})"
|
|
147
|
+
elsif due_date == Date.current
|
|
148
|
+
"Due today"
|
|
149
|
+
elsif due_date == Date.current + 1
|
|
150
|
+
"Due tomorrow"
|
|
151
|
+
else
|
|
152
|
+
"Due #{due_date.strftime('%b %d, %Y')}"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def closed_date
|
|
157
|
+
return nil unless closed?
|
|
158
|
+
closure.created_at.strftime("%B %d, %Y")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def closed_by_name
|
|
162
|
+
return nil unless closed?
|
|
163
|
+
closure.closed_by.name
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Currency and Numbers
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
class OrderPresenter < ApplicationPresenter
|
|
172
|
+
def formatted_total
|
|
173
|
+
number_to_currency(total / 100.0)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def formatted_subtotal
|
|
177
|
+
number_to_currency(subtotal / 100.0)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def formatted_tax
|
|
181
|
+
number_to_currency(tax / 100.0)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def item_count_label
|
|
185
|
+
pluralize(line_items_count, "item")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def discount_percentage
|
|
189
|
+
return nil if discount.zero?
|
|
190
|
+
number_to_percentage(discount * 100, precision: 0)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Conditional Display
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
class UserPresenter < ApplicationPresenter
|
|
199
|
+
def display_name
|
|
200
|
+
name.presence || email.split("@").first
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def avatar_initials
|
|
204
|
+
parts = name.to_s.split
|
|
205
|
+
if parts.length >= 2
|
|
206
|
+
"#{parts.first[0]}#{parts.last[0]}".upcase
|
|
207
|
+
else
|
|
208
|
+
name.to_s[0..1].upcase
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def role_label
|
|
213
|
+
role.titleize
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def contact_info
|
|
217
|
+
[email, phone].compact_blank.join(" | ")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def member_since
|
|
221
|
+
"Member since #{created_at.strftime('%B %Y')}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def last_active_label
|
|
225
|
+
if last_active_at.nil?
|
|
226
|
+
"Never active"
|
|
227
|
+
elsif last_active_at > 5.minutes.ago
|
|
228
|
+
"Online now"
|
|
229
|
+
elsif last_active_at > 1.day.ago
|
|
230
|
+
"Active #{time_ago_in_words(last_active_at)} ago"
|
|
231
|
+
else
|
|
232
|
+
"Last seen #{last_active_at.strftime('%b %d')}"
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Using Presenters in Views
|
|
239
|
+
|
|
240
|
+
### In Controllers
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
class ProjectsController < ApplicationController
|
|
244
|
+
def show
|
|
245
|
+
project = current_account.projects.find(params[:id])
|
|
246
|
+
@project = ProjectPresenter.new(project)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def index
|
|
250
|
+
projects = current_account.projects.includes(:creator, :closure)
|
|
251
|
+
@projects = projects.map { |p| ProjectPresenter.new(p) }
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### In Views
|
|
257
|
+
|
|
258
|
+
```erb
|
|
259
|
+
<%# app/views/projects/show.html.erb %>
|
|
260
|
+
<div class="flex items-center gap-2">
|
|
261
|
+
<h1><%= @project.name %></h1>
|
|
262
|
+
<span class="px-2 py-1 rounded text-sm <%= @project.status_css_class %>">
|
|
263
|
+
<%= @project.status_label %>
|
|
264
|
+
</span>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<div class="text-gray-600">
|
|
268
|
+
<p>Created by <%= @project.creator.name %> on <%= @project.created_date %></p>
|
|
269
|
+
<p><%= @project.due_date_display %></p>
|
|
270
|
+
<% if @project.closed? %>
|
|
271
|
+
<p>Closed by <%= @project.closed_by_name %> on <%= @project.closed_date %></p>
|
|
272
|
+
<% end %>
|
|
273
|
+
</div>
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### With ViewComponents
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
# Presenter provides formatted data
|
|
280
|
+
presenter = ProjectPresenter.new(project)
|
|
281
|
+
|
|
282
|
+
# ViewComponent renders the UI
|
|
283
|
+
render StatusBadgeComponent.new(
|
|
284
|
+
label: presenter.status_label,
|
|
285
|
+
color: presenter.status_color
|
|
286
|
+
)
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
## Presenting Collections
|
|
290
|
+
|
|
291
|
+
### Helper Method for Wrapping
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
# app/helpers/presenter_helper.rb
|
|
295
|
+
module PresenterHelper
|
|
296
|
+
def present(object, presenter_class = nil)
|
|
297
|
+
presenter_class ||= "#{object.class}Presenter".constantize
|
|
298
|
+
presenter_class.new(object)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def present_collection(collection, presenter_class = nil)
|
|
302
|
+
collection.map { |item| present(item, presenter_class) }
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Usage
|
|
308
|
+
|
|
309
|
+
```erb
|
|
310
|
+
<%# In views %>
|
|
311
|
+
<% present_collection(@projects).each do |project| %>
|
|
312
|
+
<div>
|
|
313
|
+
<h3><%= project.name %></h3>
|
|
314
|
+
<span class="<%= project.status_css_class %>"><%= project.status_label %></span>
|
|
315
|
+
</div>
|
|
316
|
+
<% end %>
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## File Organization
|
|
320
|
+
|
|
321
|
+
```
|
|
322
|
+
app/presenters/
|
|
323
|
+
application_presenter.rb
|
|
324
|
+
project_presenter.rb
|
|
325
|
+
task_presenter.rb
|
|
326
|
+
user_presenter.rb
|
|
327
|
+
order_presenter.rb
|
|
328
|
+
invoice_presenter.rb
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Keep presenters flat. No subdirectories needed unless you have many presenters for the same domain.
|
|
332
|
+
|
|
333
|
+
## Testing Presenters with Minitest
|
|
334
|
+
|
|
335
|
+
### Basic Presenter Tests
|
|
336
|
+
|
|
337
|
+
```ruby
|
|
338
|
+
# test/presenters/project_presenter_test.rb
|
|
339
|
+
require "test_helper"
|
|
340
|
+
|
|
341
|
+
class ProjectPresenterTest < ActiveSupport::TestCase
|
|
342
|
+
include ActionView::Helpers::NumberHelper
|
|
343
|
+
include ActionView::Helpers::DateHelper
|
|
344
|
+
include ActionView::Helpers::TextHelper
|
|
345
|
+
|
|
346
|
+
setup do
|
|
347
|
+
@project = projects(:website_redesign)
|
|
348
|
+
@presenter = ProjectPresenter.new(@project)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Status
|
|
352
|
+
test "#status returns open for active projects" do
|
|
353
|
+
assert_equal "open", @presenter.status
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
test "#status returns closed for closed projects" do
|
|
357
|
+
@project.close!(closed_by: users(:alice))
|
|
358
|
+
presenter = ProjectPresenter.new(@project)
|
|
359
|
+
assert_equal "closed", presenter.status
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
test "#status returns overdue when past due and open" do
|
|
363
|
+
@project.update!(due_date: 1.day.ago)
|
|
364
|
+
presenter = ProjectPresenter.new(@project)
|
|
365
|
+
assert_equal "overdue", presenter.status
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
test "#status_label returns human-readable label" do
|
|
369
|
+
assert_equal "Active", @presenter.status_label
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
test "#status_color returns appropriate color" do
|
|
373
|
+
assert_equal "green", @presenter.status_color
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
test "#status_css_class returns Tailwind classes" do
|
|
377
|
+
assert_equal "bg-green-100 text-green-800", @presenter.status_css_class
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Dates
|
|
381
|
+
test "#created_date formats as full date" do
|
|
382
|
+
assert_match(/\w+ \d{2}, \d{4}/, @presenter.created_date)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
test "#due_date_display shows no due date when nil" do
|
|
386
|
+
@project.update!(due_date: nil)
|
|
387
|
+
presenter = ProjectPresenter.new(@project)
|
|
388
|
+
assert_equal "No due date", presenter.due_date_display
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
test "#due_date_display shows overdue when past" do
|
|
392
|
+
@project.update!(due_date: 2.days.ago)
|
|
393
|
+
presenter = ProjectPresenter.new(@project)
|
|
394
|
+
assert_match(/Overdue/, presenter.due_date_display)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
test "#due_date_display shows due today" do
|
|
398
|
+
@project.update!(due_date: Date.current)
|
|
399
|
+
presenter = ProjectPresenter.new(@project)
|
|
400
|
+
assert_equal "Due today", presenter.due_date_display
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Delegation
|
|
404
|
+
test "delegates to underlying model" do
|
|
405
|
+
assert_equal @project.name, @presenter.name
|
|
406
|
+
assert_equal @project.id, @presenter.id
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
test "#to_model returns the original model" do
|
|
410
|
+
assert_equal @project, @presenter.to_model
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
test "#to_param delegates to model" do
|
|
414
|
+
assert_equal @project.to_param, @presenter.to_param
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
## Anti-Patterns to Avoid
|
|
420
|
+
|
|
421
|
+
1. **Business logic in presenters** - Presenters format data for display. They don't change state or enforce rules.
|
|
422
|
+
2. **Database queries in presenters** - Presenters should use already-loaded data. No `where`, `find`, or `count` calls.
|
|
423
|
+
3. **HTML in presenters** - Presenters return data (strings, colors, classes). ViewComponents generate HTML.
|
|
424
|
+
4. **Presenter inheritance chains** - Keep it flat. `ApplicationPresenter` -> `ModelPresenter`. No deeper.
|
|
425
|
+
5. **Presenters for everything** - If you're just showing `model.name`, you don't need a presenter.
|
|
426
|
+
6. **Forgetting `to_model`** - Without it, `form_for`, `link_to`, and other Rails helpers break.
|
|
427
|
+
7. **Heavy computation** - If formatting requires significant processing, consider caching or moving to a query object.
|