anima-core 0.3.0 → 1.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/.reek.yml +27 -1
- data/CHANGELOG.md +4 -0
- data/README.md +219 -25
- data/agents/codebase-analyzer.md +88 -0
- data/agents/codebase-pattern-finder.md +83 -0
- data/agents/documentation-researcher.md +59 -0
- data/agents/thoughts-analyzer.md +102 -0
- data/agents/web-search-researcher.md +71 -0
- data/anima-core.gemspec +3 -0
- data/app/channels/session_channel.rb +76 -28
- data/app/jobs/agent_request_job.rb +24 -0
- data/app/jobs/analytical_brain_job.rb +33 -0
- data/app/jobs/count_event_tokens_job.rb +1 -1
- data/app/models/concerns/event/broadcasting.rb +20 -2
- data/app/models/event.rb +1 -1
- data/app/models/goal.rb +91 -0
- data/app/models/session.rb +347 -22
- data/config/application.rb +2 -0
- data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
- data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
- data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
- data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
- data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
- data/db/migrate/20260315140843_create_goals.rb +16 -0
- data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
- data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
- data/lib/agent_loop.rb +65 -9
- data/lib/agents/definition.rb +116 -0
- data/lib/agents/registry.rb +106 -0
- data/lib/analytical_brain/runner.rb +276 -0
- data/lib/analytical_brain/tools/activate_skill.rb +52 -0
- data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
- data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
- data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
- data/lib/analytical_brain/tools/finish_goal.rb +62 -0
- data/lib/analytical_brain/tools/read_workflow.rb +58 -0
- data/lib/analytical_brain/tools/rename_session.rb +63 -0
- data/lib/analytical_brain/tools/set_goal.rb +60 -0
- data/lib/analytical_brain/tools/update_goal.rb +60 -0
- data/lib/analytical_brain.rb +23 -0
- data/lib/anima/cli/mcp/secrets.rb +76 -0
- data/lib/anima/cli/mcp.rb +197 -0
- data/lib/anima/cli.rb +4 -0
- data/lib/anima/installer.rb +168 -0
- data/lib/anima/settings.rb +226 -0
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +9 -0
- data/lib/credential_store.rb +103 -0
- data/lib/environment_probe.rb +232 -0
- data/lib/llm/client.rb +29 -10
- data/lib/mcp/client_manager.rb +86 -0
- data/lib/mcp/config.rb +213 -0
- data/lib/mcp/health_check.rb +77 -0
- data/lib/mcp/secrets.rb +73 -0
- data/lib/mcp/stdio_transport.rb +206 -0
- data/lib/providers/anthropic.rb +8 -7
- data/lib/shell_session.rb +11 -10
- data/lib/skills/definition.rb +97 -0
- data/lib/skills/registry.rb +105 -0
- data/lib/tools/edit.rb +3 -4
- data/lib/tools/mcp_tool.rb +114 -0
- data/lib/tools/read.rb +15 -16
- data/lib/tools/registry.rb +14 -12
- data/lib/tools/request_feature.rb +121 -0
- data/lib/tools/return_result.rb +81 -0
- data/lib/tools/spawn_specialist.rb +109 -0
- data/lib/tools/spawn_subagent.rb +111 -0
- data/lib/tools/subagent_prompts.rb +12 -0
- data/lib/tools/web_get.rb +8 -9
- data/lib/tui/app.rb +332 -43
- data/lib/tui/message_store.rb +20 -0
- data/lib/tui/screens/chat.rb +207 -20
- data/lib/workflows/definition.rb +97 -0
- data/lib/workflows/registry.rb +89 -0
- data/skills/activerecord/SKILL.md +255 -0
- data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
- data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
- data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
- data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
- data/skills/activerecord/examples/associations/self_referential.rb +302 -0
- data/skills/activerecord/examples/associations/through_associations.rb +203 -0
- data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
- data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
- data/skills/activerecord/examples/basics/inheritance.rb +377 -0
- data/skills/activerecord/examples/basics/type_casting.rb +317 -0
- data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
- data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
- data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
- data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
- data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
- data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
- data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
- data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
- data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
- data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
- data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
- data/skills/activerecord/examples/querying/optimization.rb +275 -0
- data/skills/activerecord/examples/querying/scopes.rb +260 -0
- data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
- data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
- data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
- data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
- data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
- data/skills/activerecord/references/associations.md +709 -0
- data/skills/activerecord/references/basics.md +622 -0
- data/skills/activerecord/references/callbacks.md +738 -0
- data/skills/activerecord/references/migrations.md +657 -0
- data/skills/activerecord/references/querying.md +655 -0
- data/skills/activerecord/references/validations.md +596 -0
- data/skills/dragonruby/SKILL.md +250 -0
- data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
- data/skills/dragonruby/examples/audio/background_music.rb +29 -0
- data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
- data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
- data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
- data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
- data/skills/dragonruby/examples/core/hello_world.rb +24 -0
- data/skills/dragonruby/examples/core/labels.rb +22 -0
- data/skills/dragonruby/examples/core/sprites.rb +35 -0
- data/skills/dragonruby/examples/core/state_management.rb +29 -0
- data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
- data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
- data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
- data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
- data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
- data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
- data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
- data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
- data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
- data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
- data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
- data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
- data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
- data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
- data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
- data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
- data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
- data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
- data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
- data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
- data/skills/dragonruby/examples/input/controller_input.rb +28 -0
- data/skills/dragonruby/examples/input/directional_input.rb +24 -0
- data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
- data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
- data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
- data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
- data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
- data/skills/dragonruby/examples/rendering/labels.rb +32 -0
- data/skills/dragonruby/examples/rendering/layering.rb +51 -0
- data/skills/dragonruby/examples/rendering/solids.rb +61 -0
- data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
- data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
- data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
- data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
- data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
- data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
- data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
- data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
- data/skills/dragonruby/references/audio.md +396 -0
- data/skills/dragonruby/references/core.md +385 -0
- data/skills/dragonruby/references/distribution.md +434 -0
- data/skills/dragonruby/references/entities.md +516 -0
- data/skills/dragonruby/references/game-logic/persistence.md +386 -0
- data/skills/dragonruby/references/game-logic/state.md +389 -0
- data/skills/dragonruby/references/input.md +414 -0
- data/skills/dragonruby/references/rendering/animation.md +467 -0
- data/skills/dragonruby/references/rendering/primitives.md +403 -0
- data/skills/dragonruby/references/scenes.md +443 -0
- data/skills/draper-decorators/SKILL.md +344 -0
- data/skills/draper-decorators/examples/application_decorator.rb +61 -0
- data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
- data/skills/draper-decorators/examples/model_decorator.rb +152 -0
- data/skills/draper-decorators/references/anti-patterns.md +640 -0
- data/skills/draper-decorators/references/patterns.md +507 -0
- data/skills/draper-decorators/references/testing.md +559 -0
- data/skills/gh-issue.md +182 -0
- data/skills/mcp-server/SKILL.md +177 -0
- data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
- data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
- data/skills/mcp-server/examples/http_client.rb +48 -0
- data/skills/mcp-server/examples/http_server.rb +97 -0
- data/skills/mcp-server/examples/rails_integration.rb +88 -0
- data/skills/mcp-server/examples/stdio_server.rb +108 -0
- data/skills/mcp-server/examples/streaming_client.rb +95 -0
- data/skills/mcp-server/references/gotchas.md +183 -0
- data/skills/mcp-server/references/prompts.md +98 -0
- data/skills/mcp-server/references/resources.md +53 -0
- data/skills/mcp-server/references/server.md +140 -0
- data/skills/mcp-server/references/tools.md +146 -0
- data/skills/mcp-server/references/transport.md +104 -0
- data/skills/ratatui-ruby/SKILL.md +315 -0
- data/skills/ratatui-ruby/references/core-concepts.md +340 -0
- data/skills/ratatui-ruby/references/events.md +387 -0
- data/skills/ratatui-ruby/references/frameworks.md +522 -0
- data/skills/ratatui-ruby/references/layout.md +423 -0
- data/skills/ratatui-ruby/references/styling.md +268 -0
- data/skills/ratatui-ruby/references/testing.md +433 -0
- data/skills/ratatui-ruby/references/widgets.md +532 -0
- data/skills/rspec/SKILL.md +340 -0
- data/skills/rspec/examples/core/basic_structure.rb +69 -0
- data/skills/rspec/examples/core/configuration.rb +126 -0
- data/skills/rspec/examples/core/hooks.rb +126 -0
- data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
- data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
- data/skills/rspec/examples/core/shared_examples.rb +145 -0
- data/skills/rspec/examples/factory_bot/associations.rb +314 -0
- data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
- data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
- data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
- data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
- data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
- data/skills/rspec/examples/factory_bot/traits.rb +293 -0
- data/skills/rspec/examples/factory_bot/transients.rb +229 -0
- data/skills/rspec/examples/matchers/change.rb +115 -0
- data/skills/rspec/examples/matchers/collections.rb +154 -0
- data/skills/rspec/examples/matchers/comparisons.rb +79 -0
- data/skills/rspec/examples/matchers/composing.rb +155 -0
- data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
- data/skills/rspec/examples/matchers/equality.rb +58 -0
- data/skills/rspec/examples/matchers/errors.rb +136 -0
- data/skills/rspec/examples/matchers/output.rb +103 -0
- data/skills/rspec/examples/matchers/predicates.rb +87 -0
- data/skills/rspec/examples/matchers/truthiness.rb +101 -0
- data/skills/rspec/examples/matchers/types.rb +82 -0
- data/skills/rspec/examples/matchers/yield.rb +147 -0
- data/skills/rspec/examples/mocks/any_instance.rb +172 -0
- data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
- data/skills/rspec/examples/mocks/constants.rb +177 -0
- data/skills/rspec/examples/mocks/doubles.rb +139 -0
- data/skills/rspec/examples/mocks/expectations.rb +137 -0
- data/skills/rspec/examples/mocks/message_chains.rb +173 -0
- data/skills/rspec/examples/mocks/ordering.rb +144 -0
- data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
- data/skills/rspec/examples/mocks/responses.rb +223 -0
- data/skills/rspec/examples/mocks/spies.rb +149 -0
- data/skills/rspec/examples/mocks/stubbing.rb +133 -0
- data/skills/rspec/examples/rails/channels.rb +250 -0
- data/skills/rspec/examples/rails/controller_specs.rb +302 -0
- data/skills/rspec/examples/rails/helper_specs.rb +245 -0
- data/skills/rspec/examples/rails/job_specs.rb +256 -0
- data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
- data/skills/rspec/examples/rails/matchers.rb +374 -0
- data/skills/rspec/examples/rails/model_specs.rb +193 -0
- data/skills/rspec/examples/rails/request_specs.rb +275 -0
- data/skills/rspec/examples/rails/routing_specs.rb +276 -0
- data/skills/rspec/examples/rails/system_specs.rb +294 -0
- data/skills/rspec/examples/rails/transactions.rb +254 -0
- data/skills/rspec/examples/rails/view_specs.rb +252 -0
- data/skills/rspec/references/core.md +816 -0
- data/skills/rspec/references/factory_bot.md +641 -0
- data/skills/rspec/references/matchers.md +516 -0
- data/skills/rspec/references/mocks.md +381 -0
- data/skills/rspec/references/rails.md +528 -0
- data/templates/soul.md +40 -0
- data/workflows/commit.md +45 -0
- data/workflows/create_handoff.md +98 -0
- data/workflows/create_note.md +82 -0
- data/workflows/create_plan.md +457 -0
- data/workflows/decompose_ticket.md +109 -0
- data/workflows/feature.md +91 -0
- data/workflows/implement_plan.md +87 -0
- data/workflows/iterate_plan.md +247 -0
- data/workflows/research_codebase.md +210 -0
- data/workflows/resume_handoff.md +217 -0
- data/workflows/review_pr.md +320 -0
- data/workflows/thoughts_init.md +71 -0
- data/workflows/validate_plan.md +166 -0
- metadata +284 -1
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: draper-decorators
|
|
3
|
+
description: "Draper decorator patterns for Rails views. Activate when creating or testing decorators, moving formatting logic out of models/views, editing *_decorator.rb files, or working in app/decorators/."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Draper Decorators for Rails
|
|
7
|
+
|
|
8
|
+
This skill provides guidance for creating effective Draper decorators in Rails applications.
|
|
9
|
+
|
|
10
|
+
## Philosophy
|
|
11
|
+
|
|
12
|
+
Decorators implement separation of concerns between **business logic** (models) and **presentation logic** (views). A decorator wraps a model to add view-specific methods without polluting the model.
|
|
13
|
+
|
|
14
|
+
**What belongs in decorators:**
|
|
15
|
+
- Date/time formatting (`created_at.strftime("%B %d, %Y")`)
|
|
16
|
+
- String concatenation (`"#{first_name} #{last_name}"`)
|
|
17
|
+
- HTML generation (`h.content_tag(:span, status, class: css_class)`)
|
|
18
|
+
- Conditional rendering based on state
|
|
19
|
+
- Number formatting (currency, percentages)
|
|
20
|
+
- CSS class generation based on object state
|
|
21
|
+
|
|
22
|
+
**What does NOT belong in decorators:**
|
|
23
|
+
- Business logic (validations, calculations, state changes)
|
|
24
|
+
- Database queries (use includes in controllers)
|
|
25
|
+
- Anything not directly related to presentation
|
|
26
|
+
|
|
27
|
+
## Basic Structure
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
# app/decorators/user_decorator.rb
|
|
31
|
+
class UserDecorator < ApplicationDecorator
|
|
32
|
+
delegate_all
|
|
33
|
+
|
|
34
|
+
def full_name
|
|
35
|
+
"#{first_name} #{last_name}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def formatted_created_at
|
|
39
|
+
created_at.strftime("%B %d, %Y")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def status_badge
|
|
43
|
+
css_class = active? ? "badge-success" : "badge-secondary"
|
|
44
|
+
h.content_tag(:span, status, class: "badge #{css_class}")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Delegation Strategies
|
|
50
|
+
|
|
51
|
+
### Option 1: `delegate_all` (Convenient)
|
|
52
|
+
|
|
53
|
+
Delegates all methods to the wrapped object via `method_missing`. Use for most decorators.
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
class ProductDecorator < ApplicationDecorator
|
|
57
|
+
delegate_all
|
|
58
|
+
|
|
59
|
+
def formatted_price
|
|
60
|
+
h.number_to_currency(price)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Option 2: Explicit Delegation (Strict)
|
|
66
|
+
|
|
67
|
+
Explicitly declare which methods to delegate. Use for larger apps where control matters.
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
class ProductDecorator < ApplicationDecorator
|
|
71
|
+
delegate :id, :name, :price, :created_at, :persisted?
|
|
72
|
+
|
|
73
|
+
def formatted_price
|
|
74
|
+
h.number_to_currency(price)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Accessing the Wrapped Object
|
|
80
|
+
|
|
81
|
+
Three equivalent ways to access the model:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
class ArticleDecorator < ApplicationDecorator
|
|
85
|
+
delegate_all
|
|
86
|
+
|
|
87
|
+
def display_title
|
|
88
|
+
object.title.upcase # via 'object'
|
|
89
|
+
model.title.upcase # via 'model' (alias)
|
|
90
|
+
article.title.upcase # via model name (auto-generated)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Accessing Rails Helpers
|
|
96
|
+
|
|
97
|
+
Use `h` or `helpers` to access view helpers:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
class PostDecorator < ApplicationDecorator
|
|
101
|
+
delegate_all
|
|
102
|
+
|
|
103
|
+
def formatted_body
|
|
104
|
+
h.simple_format(body)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def edit_link
|
|
108
|
+
h.link_to("Edit", h.edit_post_path(object), class: "btn")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def publication_date
|
|
112
|
+
h.l(published_at, format: :long) # l is localize alias
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Decorating in Controllers
|
|
118
|
+
|
|
119
|
+
Decorate **at the last moment**, right before rendering:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
class PostsController < ApplicationController
|
|
123
|
+
def show
|
|
124
|
+
@post = Post.find(params[:id]).decorate
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def index
|
|
128
|
+
@posts = Post.includes(:author).all.decorate
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Critical:** Always use `includes` BEFORE decorating to avoid N+1 queries.
|
|
134
|
+
|
|
135
|
+
## Association Decoration
|
|
136
|
+
|
|
137
|
+
Use `decorates_association` to auto-decorate associations:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
class PostDecorator < ApplicationDecorator
|
|
141
|
+
delegate_all
|
|
142
|
+
decorates_association :author
|
|
143
|
+
decorates_association :comments
|
|
144
|
+
decorates_association :recent_comments, scope: :recent
|
|
145
|
+
end
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
In views, `@post.author` returns `AuthorDecorator`, not `Author`.
|
|
149
|
+
|
|
150
|
+
## Context Passing
|
|
151
|
+
|
|
152
|
+
Pass extra data to decorators via context:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
# Controller
|
|
156
|
+
@product = Product.find(params[:id]).decorate(context: { current_user: })
|
|
157
|
+
|
|
158
|
+
# Decorator
|
|
159
|
+
class ProductDecorator < ApplicationDecorator
|
|
160
|
+
delegate_all
|
|
161
|
+
|
|
162
|
+
def admin_price_info
|
|
163
|
+
return unless context[:current_user]&.admin?
|
|
164
|
+
"Cost: #{h.number_to_currency(cost)} | Margin: #{margin}%"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Collection Decoration
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
# Auto-infers decorator from model
|
|
173
|
+
@products = Product.all.decorate
|
|
174
|
+
|
|
175
|
+
# Explicit decorator
|
|
176
|
+
@products = ProductDecorator.decorate_collection(Product.all)
|
|
177
|
+
|
|
178
|
+
# With pagination (use custom collection decorator)
|
|
179
|
+
class PaginatingDecorator < Draper::CollectionDecorator
|
|
180
|
+
delegate :current_page, :total_pages, :limit_value
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
class ProductDecorator < ApplicationDecorator
|
|
184
|
+
def self.collection_decorator_class
|
|
185
|
+
PaginatingDecorator
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Testing Decorators
|
|
191
|
+
|
|
192
|
+
Place specs in `spec/decorators/`. Draper auto-configures RSpec integration.
|
|
193
|
+
|
|
194
|
+
### Basic Pattern
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
# spec/decorators/user_decorator_spec.rb
|
|
198
|
+
require 'rails_helper'
|
|
199
|
+
|
|
200
|
+
RSpec.describe UserDecorator do
|
|
201
|
+
subject(:decorator) { described_class.new(user) }
|
|
202
|
+
|
|
203
|
+
let(:user) { build_stubbed(:user, first_name: "John", last_name: "Doe") }
|
|
204
|
+
|
|
205
|
+
describe "#full_name" do
|
|
206
|
+
subject(:full_name) { decorator.full_name }
|
|
207
|
+
|
|
208
|
+
it "combines first and last name" do
|
|
209
|
+
expect(full_name).to eq("John Doe")
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
describe "#formatted_created_at" do
|
|
214
|
+
subject(:formatted_date) { decorator.formatted_created_at }
|
|
215
|
+
|
|
216
|
+
let(:user) { build_stubbed(:user, created_at: Time.zone.parse("2024-01-15")) }
|
|
217
|
+
|
|
218
|
+
it "formats date in long format" do
|
|
219
|
+
expect(formatted_date).to eq("January 15, 2024")
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Testing with Helpers
|
|
226
|
+
|
|
227
|
+
Access helpers via `helpers` method in tests:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
RSpec.describe PostDecorator do
|
|
231
|
+
subject(:decorator) { described_class.new(post) }
|
|
232
|
+
|
|
233
|
+
let(:post) { create(:post) }
|
|
234
|
+
|
|
235
|
+
it "generates correct path" do
|
|
236
|
+
expect(decorator.edit_link).to include(helpers.edit_post_path(post))
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Testing HTML Output with Capybara
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
RSpec.describe StatusDecorator do
|
|
245
|
+
subject(:decorator) { described_class.new(order) }
|
|
246
|
+
|
|
247
|
+
describe "#status_badge" do
|
|
248
|
+
subject(:badge) { decorator.status_badge }
|
|
249
|
+
|
|
250
|
+
context "when completed" do
|
|
251
|
+
let(:order) { build_stubbed(:order, :completed) }
|
|
252
|
+
|
|
253
|
+
it "renders success badge" do
|
|
254
|
+
markup = Capybara.string(badge)
|
|
255
|
+
expect(markup).to have_css("span.badge-success", text: "Completed")
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Common Anti-Patterns
|
|
263
|
+
|
|
264
|
+
### Fat Decorator
|
|
265
|
+
|
|
266
|
+
Split large decorators into context-specific ones:
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
# Instead of one 500-line UserDecorator, use:
|
|
270
|
+
class Users::ProfileDecorator < ApplicationDecorator
|
|
271
|
+
# Profile-related presentation
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
class Users::AdminDecorator < ApplicationDecorator
|
|
275
|
+
# Admin panel presentation
|
|
276
|
+
end
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### N+1 Queries
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
# BAD - triggers N+1
|
|
283
|
+
@posts = Post.all.decorate
|
|
284
|
+
# In decorator: author.name triggers query per post
|
|
285
|
+
|
|
286
|
+
# GOOD - eager load first
|
|
287
|
+
@posts = Post.includes(:author).all.decorate
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Decorating Too Early
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
# BAD - decorated objects in business logic
|
|
294
|
+
def publish(decorated_post)
|
|
295
|
+
decorated_post.update(published: true)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# GOOD - use models for business logic
|
|
299
|
+
def publish(post)
|
|
300
|
+
post.update(published: true)
|
|
301
|
+
end
|
|
302
|
+
# Decorate only in controller before render
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### Using Decorators in Models
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
# BAD - model references decorator
|
|
309
|
+
class Post < ApplicationRecord
|
|
310
|
+
def display_title
|
|
311
|
+
PostDecorator.new(self).formatted_title
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# GOOD - keep models unaware of decorators
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
## Quick Reference
|
|
319
|
+
|
|
320
|
+
| Method | Purpose |
|
|
321
|
+
|--------|---------|
|
|
322
|
+
| `object` / `model` | Access wrapped object |
|
|
323
|
+
| `h` / `helpers` | Access view helpers |
|
|
324
|
+
| `context` | Access passed context hash |
|
|
325
|
+
| `delegate_all` | Delegate all methods to object |
|
|
326
|
+
| `decorates_association` | Auto-decorate associations |
|
|
327
|
+
| `decorate` | Decorate single object |
|
|
328
|
+
| `decorate_collection` | Decorate collection |
|
|
329
|
+
|
|
330
|
+
## Additional Resources
|
|
331
|
+
|
|
332
|
+
### Reference Files
|
|
333
|
+
|
|
334
|
+
For detailed patterns and examples:
|
|
335
|
+
- **`references/patterns.md`** - Advanced patterns, association decoration, context handling
|
|
336
|
+
- **`references/testing.md`** - Comprehensive RSpec testing guide
|
|
337
|
+
- **`references/anti-patterns.md`** - Detailed anti-patterns with solutions
|
|
338
|
+
|
|
339
|
+
### Example Files
|
|
340
|
+
|
|
341
|
+
Working examples in `examples/`:
|
|
342
|
+
- **`examples/application_decorator.rb`** - Base decorator template
|
|
343
|
+
- **`examples/model_decorator.rb`** - Full decorator example
|
|
344
|
+
- **`examples/decorator_spec.rb`** - Complete spec template
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# app/decorators/application_decorator.rb
|
|
2
|
+
#
|
|
3
|
+
# Base decorator with shared presentation methods.
|
|
4
|
+
# All decorators should inherit from this class.
|
|
5
|
+
#
|
|
6
|
+
class ApplicationDecorator < Draper::Decorator
|
|
7
|
+
# Common date formatting
|
|
8
|
+
#
|
|
9
|
+
# @param date [Time, Date, nil] the date to format
|
|
10
|
+
# @param format [Symbol] the I18n format key
|
|
11
|
+
# @return [String] formatted date or "N/A"
|
|
12
|
+
def formatted_date(date = created_at, format: :long)
|
|
13
|
+
return "N/A" if date.blank?
|
|
14
|
+
|
|
15
|
+
h.l(date, format:)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Relative time (e.g., "2 hours ago")
|
|
19
|
+
#
|
|
20
|
+
# @param time [Time] the time to format
|
|
21
|
+
# @return [String] relative time string
|
|
22
|
+
def time_ago(time = created_at)
|
|
23
|
+
"#{h.time_ago_in_words(time)} ago"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Currency formatting
|
|
27
|
+
#
|
|
28
|
+
# @param amount [Numeric, nil] the amount to format
|
|
29
|
+
# @return [String] formatted currency
|
|
30
|
+
def formatted_currency(amount)
|
|
31
|
+
return "$0.00" if amount.blank?
|
|
32
|
+
|
|
33
|
+
h.number_to_currency(amount)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Truncate text with word boundary
|
|
37
|
+
#
|
|
38
|
+
# @param text [String] text to truncate
|
|
39
|
+
# @param length [Integer] maximum length
|
|
40
|
+
# @return [String] truncated text
|
|
41
|
+
def truncated_text(text, length: 100)
|
|
42
|
+
h.truncate(text.to_s, length:, separator: " ")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Safe boolean display
|
|
46
|
+
#
|
|
47
|
+
# @param value [Boolean] the boolean to display
|
|
48
|
+
# @return [String] "Yes" or "No"
|
|
49
|
+
def boolean_display(value)
|
|
50
|
+
value ? "Yes" : "No"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Status badge helper
|
|
54
|
+
#
|
|
55
|
+
# @param text [String] badge text
|
|
56
|
+
# @param type [Symbol] badge type (:success, :warning, :danger, :info, :secondary)
|
|
57
|
+
# @return [String] HTML span element
|
|
58
|
+
def badge(text, type: :secondary)
|
|
59
|
+
h.content_tag(:span, text, class: "badge badge-#{type}")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# spec/decorators/post_decorator_spec.rb
|
|
2
|
+
#
|
|
3
|
+
# Complete decorator spec template demonstrating best practices.
|
|
4
|
+
#
|
|
5
|
+
require "rails_helper"
|
|
6
|
+
|
|
7
|
+
RSpec.describe PostDecorator do
|
|
8
|
+
subject(:decorator) { described_class.new(post, context:) }
|
|
9
|
+
|
|
10
|
+
let(:post) { build_stubbed(:post, attributes) }
|
|
11
|
+
let(:attributes) { {} }
|
|
12
|
+
let(:context) { {} }
|
|
13
|
+
|
|
14
|
+
describe "#formatted_title" do
|
|
15
|
+
subject(:formatted_title) { decorator.formatted_title }
|
|
16
|
+
|
|
17
|
+
let(:attributes) { { title: "my post title" } }
|
|
18
|
+
|
|
19
|
+
it "titleizes the title" do
|
|
20
|
+
expect(formatted_title).to eq("My Post Title")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
context "with max_length" do
|
|
24
|
+
subject(:formatted_title) { decorator.formatted_title(max_length: 10) }
|
|
25
|
+
|
|
26
|
+
let(:attributes) { { title: "a very long post title" } }
|
|
27
|
+
|
|
28
|
+
it "truncates to specified length" do
|
|
29
|
+
expect(formatted_title.length).to be <= 13 # includes "..."
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
describe "#status_badge" do
|
|
35
|
+
subject(:badge) { decorator.status_badge }
|
|
36
|
+
|
|
37
|
+
context "when published" do
|
|
38
|
+
let(:post) { build_stubbed(:post, :published) }
|
|
39
|
+
|
|
40
|
+
it "renders success badge" do
|
|
41
|
+
markup = Capybara.string(badge)
|
|
42
|
+
|
|
43
|
+
expect(markup).to have_css("span.badge.badge-success", text: "Published")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
context "when draft" do
|
|
48
|
+
let(:post) { build_stubbed(:post, :draft) }
|
|
49
|
+
|
|
50
|
+
it "renders warning badge" do
|
|
51
|
+
markup = Capybara.string(badge)
|
|
52
|
+
|
|
53
|
+
expect(markup).to have_css("span.badge.badge-warning", text: "Draft")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
describe "#publication_date" do
|
|
59
|
+
subject(:publication_date) { decorator.publication_date }
|
|
60
|
+
|
|
61
|
+
context "when published" do
|
|
62
|
+
let(:attributes) { { published_at: Time.zone.parse("2024-03-15") } }
|
|
63
|
+
|
|
64
|
+
it "formats the date" do
|
|
65
|
+
expect(publication_date).to eq("March 15, 2024")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
context "when not published" do
|
|
70
|
+
let(:attributes) { { published_at: nil } }
|
|
71
|
+
|
|
72
|
+
it "returns not published message" do
|
|
73
|
+
expect(publication_date).to eq("Not published")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
describe "#reading_time" do
|
|
79
|
+
subject(:reading_time) { decorator.reading_time }
|
|
80
|
+
|
|
81
|
+
let(:attributes) { { body: "word " * 400 } }
|
|
82
|
+
|
|
83
|
+
it "calculates reading time" do
|
|
84
|
+
expect(reading_time).to eq("2 min read")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
describe "#excerpt" do
|
|
89
|
+
subject(:excerpt) { decorator.excerpt }
|
|
90
|
+
|
|
91
|
+
let(:attributes) { { body: "a " * 150 } }
|
|
92
|
+
|
|
93
|
+
it "truncates body" do
|
|
94
|
+
expect(excerpt.length).to be <= 203 # 200 + "..."
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "truncates at word boundary" do
|
|
98
|
+
expect(excerpt).not_to end_with("a...")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
describe "#edit_link" do
|
|
103
|
+
subject(:link) { decorator.edit_link }
|
|
104
|
+
|
|
105
|
+
context "without current_user" do
|
|
106
|
+
let(:context) { {} }
|
|
107
|
+
|
|
108
|
+
it "returns nil" do
|
|
109
|
+
expect(link).to be_nil
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
context "with user who cannot edit" do
|
|
114
|
+
let(:context) { { current_user: build_stubbed(:user) } }
|
|
115
|
+
|
|
116
|
+
before do
|
|
117
|
+
allow(context[:current_user]).to receive(:can?).with(:edit, post).and_return(false)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "returns nil" do
|
|
121
|
+
expect(link).to be_nil
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
context "with user who can edit" do
|
|
126
|
+
let(:post) { create(:post) }
|
|
127
|
+
let(:context) { { current_user: build_stubbed(:user) } }
|
|
128
|
+
|
|
129
|
+
before do
|
|
130
|
+
allow(context[:current_user]).to receive(:can?).with(:edit, post).and_return(true)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "renders edit link" do
|
|
134
|
+
markup = Capybara.string(link)
|
|
135
|
+
|
|
136
|
+
expect(markup).to have_link("Edit", href: "/posts/#{post.id}/edit")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "has button classes" do
|
|
140
|
+
markup = Capybara.string(link)
|
|
141
|
+
|
|
142
|
+
expect(markup).to have_css("a.btn.btn-sm.btn-secondary")
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
describe "#delete_link" do
|
|
148
|
+
subject(:link) { decorator.delete_link }
|
|
149
|
+
|
|
150
|
+
context "with user who can delete" do
|
|
151
|
+
let(:post) { create(:post) }
|
|
152
|
+
let(:context) { { current_user: build_stubbed(:user) } }
|
|
153
|
+
|
|
154
|
+
before do
|
|
155
|
+
allow(context[:current_user]).to receive(:can?).with(:delete, post).and_return(true)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "renders delete link with confirmation" do
|
|
159
|
+
markup = Capybara.string(link)
|
|
160
|
+
|
|
161
|
+
expect(markup).to have_css("a[data-confirm='Are you sure?']", text: "Delete")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
describe "#action_buttons" do
|
|
167
|
+
subject(:buttons) { decorator.action_buttons }
|
|
168
|
+
|
|
169
|
+
context "with no permissions" do
|
|
170
|
+
let(:context) { {} }
|
|
171
|
+
|
|
172
|
+
it "returns nil" do
|
|
173
|
+
expect(buttons).to be_nil
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
context "with edit permission" do
|
|
178
|
+
let(:post) { create(:post) }
|
|
179
|
+
let(:context) { { current_user: build_stubbed(:user) } }
|
|
180
|
+
|
|
181
|
+
before do
|
|
182
|
+
allow(context[:current_user]).to receive(:can?).with(:edit, post).and_return(true)
|
|
183
|
+
allow(context[:current_user]).to receive(:can?).with(:delete, post).and_return(false)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it "renders button group" do
|
|
187
|
+
markup = Capybara.string(buttons)
|
|
188
|
+
|
|
189
|
+
expect(markup).to have_css("div.btn-group")
|
|
190
|
+
expect(markup).to have_link("Edit")
|
|
191
|
+
expect(markup).not_to have_link("Delete")
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
describe "associations" do
|
|
197
|
+
describe "#author" do
|
|
198
|
+
subject(:author) { decorator.author }
|
|
199
|
+
|
|
200
|
+
let(:post) { create(:post) }
|
|
201
|
+
|
|
202
|
+
it "returns decorated author" do
|
|
203
|
+
expect(author).to be_decorated_with(AuthorDecorator)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
describe "#comments" do
|
|
208
|
+
subject(:comments) { decorator.comments }
|
|
209
|
+
|
|
210
|
+
let(:post) { create(:post) }
|
|
211
|
+
|
|
212
|
+
before { create_list(:comment, 3, post:) }
|
|
213
|
+
|
|
214
|
+
it "returns decorated comments" do
|
|
215
|
+
expect(comments).to all(be_decorated_with(CommentDecorator))
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
it "includes all comments" do
|
|
219
|
+
expect(comments.size).to eq(3)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
describe "#author_info" do
|
|
225
|
+
subject(:info) { decorator.author_info }
|
|
226
|
+
|
|
227
|
+
let(:post) { create(:post) }
|
|
228
|
+
|
|
229
|
+
it "renders author info block" do
|
|
230
|
+
markup = Capybara.string(info)
|
|
231
|
+
|
|
232
|
+
expect(markup).to have_css("div.author-info")
|
|
233
|
+
expect(markup).to have_css("span.author-name")
|
|
234
|
+
expect(markup).to have_css("span.post-date")
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
describe "#meta_info" do
|
|
239
|
+
subject(:meta) { decorator.meta_info }
|
|
240
|
+
|
|
241
|
+
let(:post) { create(:post, :with_category) }
|
|
242
|
+
|
|
243
|
+
before { create_list(:comment, 2, post:) }
|
|
244
|
+
|
|
245
|
+
it "renders meta information" do
|
|
246
|
+
markup = Capybara.string(meta)
|
|
247
|
+
|
|
248
|
+
expect(markup).to have_css("div.post-meta")
|
|
249
|
+
expect(markup).to have_link(post.category.name)
|
|
250
|
+
expect(markup.text).to include("2 comments")
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|