rails-hyperdrive-layered-rails 0.1.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 +7 -0
- data/LICENSE +30 -0
- data/README.md +89 -0
- data/UPSTREAM +4 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/SKILL.md.erb +315 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/authorization-to-policy.md +79 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/callbacks-to-service.md +72 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/complex-input-to-form-object.md +119 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/current-from-model.md +63 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/god-object-decomposition.md +81 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/implicit-to-explicit-state-machine.md +77 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/query-to-query-object.md +63 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/view-logic-to-presenter.md +60 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/callbacks.md +114 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/concerns.md +92 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/helpers.md +66 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/jobs.md +71 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/layer-violations.md +176 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/service-objects.md +131 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/testing.md +37 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/core/architecture-layers.md +204 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/core/extraction-signals.md +314 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/core/specification-test.md +229 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/action-policy.md +437 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/active-agent.md +275 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/active-delivery.md +221 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/active-job-performs.md +171 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/alba.md +257 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/anyway-config.md +212 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/rubanok.md +246 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/view-component.md +223 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/workflow.md +305 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/collaborator-objects.md +209 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/concerns.md +381 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/filter-objects.md +222 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/form-objects.md +267 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/policy-objects.md +298 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/presenters.md +257 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/query-objects.md +208 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/repositories.md +382 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/serializers.md +256 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/service-objects.md +170 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/state-machines.md +368 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/value-objects.md +267 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/ai-integration.md +372 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/authorization.md +398 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/callbacks.md +336 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/configuration.md +383 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/current-attributes.md +307 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/instrumentation.md +386 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/notifications.md +373 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/view-components.md +454 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/analyze-callbacks.md +255 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/analyze-gods.md +279 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/analyze-services.md +1444 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/analyze.md +376 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/plan.md +249 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/review.md +473 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/spec-test.md +282 -0
- data/lib/rails-hyperdrive-layered-rails/version.rb +3 -0
- data/lib/rails-hyperdrive-layered-rails.rb +7 -0
- metadata +106 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Anyway Config
|
|
2
|
+
|
|
3
|
+
Configuration management with typed attributes and multiple sources.
|
|
4
|
+
|
|
5
|
+
**GitHub**: https://github.com/palkan/anyway_config
|
|
6
|
+
**Layer**: Infrastructure
|
|
7
|
+
|
|
8
|
+
## Contents
|
|
9
|
+
|
|
10
|
+
- Installation
|
|
11
|
+
- Basic Usage
|
|
12
|
+
- Base Config Class
|
|
13
|
+
- Nested Configuration
|
|
14
|
+
- Type Coercion
|
|
15
|
+
- Validation
|
|
16
|
+
- Environment Sources (Priority)
|
|
17
|
+
- Local Overrides
|
|
18
|
+
- Testing
|
|
19
|
+
- Rails Credentials
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# Gemfile
|
|
25
|
+
gem "anyway_config"
|
|
26
|
+
|
|
27
|
+
# Generate config
|
|
28
|
+
rails generate anyway:config stripe
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Basic Usage
|
|
32
|
+
|
|
33
|
+
### Define Config
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# app/configs/stripe_config.rb
|
|
37
|
+
class StripeConfig < Anyway::Config
|
|
38
|
+
attr_config :api_key,
|
|
39
|
+
:webhook_secret,
|
|
40
|
+
:publishable_key,
|
|
41
|
+
currency: "usd"
|
|
42
|
+
|
|
43
|
+
required :api_key, :webhook_secret
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### YAML Source
|
|
48
|
+
|
|
49
|
+
```yaml
|
|
50
|
+
# config/stripe.yml
|
|
51
|
+
default:
|
|
52
|
+
currency: usd
|
|
53
|
+
|
|
54
|
+
development:
|
|
55
|
+
api_key: sk_test_xxx
|
|
56
|
+
webhook_secret: whsec_xxx
|
|
57
|
+
publishable_key: pk_test_xxx
|
|
58
|
+
|
|
59
|
+
production:
|
|
60
|
+
api_key: <%= ENV["STRIPE_API_KEY"] %>
|
|
61
|
+
webhook_secret: <%= ENV["STRIPE_WEBHOOK_SECRET"] %>
|
|
62
|
+
publishable_key: <%= ENV["STRIPE_PUBLISHABLE_KEY"] %>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Access Config
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
StripeConfig.new.api_key
|
|
69
|
+
# Or with singleton pattern
|
|
70
|
+
StripeConfig.api_key
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Base Config Class
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
# app/configs/application_config.rb
|
|
77
|
+
class ApplicationConfig < Anyway::Config
|
|
78
|
+
class << self
|
|
79
|
+
def instance
|
|
80
|
+
@instance ||= new
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
delegate_missing_to :instance
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class StripeConfig < ApplicationConfig
|
|
88
|
+
# ...
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Usage
|
|
92
|
+
StripeConfig.api_key
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Nested Configuration
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
class EmailConfig < ApplicationConfig
|
|
99
|
+
attr_config :from_address,
|
|
100
|
+
:reply_to,
|
|
101
|
+
smtp: {
|
|
102
|
+
host: "localhost",
|
|
103
|
+
port: 25,
|
|
104
|
+
username: nil,
|
|
105
|
+
password: nil
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Access
|
|
110
|
+
EmailConfig.smtp.host
|
|
111
|
+
EmailConfig.smtp.port
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Type Coercion
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
class AppConfig < ApplicationConfig
|
|
118
|
+
attr_config :port,
|
|
119
|
+
:debug,
|
|
120
|
+
:allowed_hosts
|
|
121
|
+
|
|
122
|
+
coerce_types port: :integer,
|
|
123
|
+
debug: :boolean,
|
|
124
|
+
allowed_hosts: { type: :string, array: true }
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Validation
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
class DatabaseConfig < ApplicationConfig
|
|
132
|
+
attr_config :host, :port, :pool_size
|
|
133
|
+
|
|
134
|
+
required :host
|
|
135
|
+
|
|
136
|
+
on_load do
|
|
137
|
+
raise_validation_error("port must be positive") if port && port <= 0
|
|
138
|
+
raise_validation_error("pool_size must be positive") if pool_size <= 0
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Environment Sources (Priority)
|
|
144
|
+
|
|
145
|
+
1. **Environment variables** (highest)
|
|
146
|
+
- `STRIPE_API_KEY`, `STRIPE_CURRENCY`
|
|
147
|
+
|
|
148
|
+
2. **Local files** (gitignored)
|
|
149
|
+
- `config/stripe.local.yml`
|
|
150
|
+
|
|
151
|
+
3. **Environment YAML**
|
|
152
|
+
- `config/stripe.yml` (under environment key)
|
|
153
|
+
|
|
154
|
+
4. **Default values** (lowest)
|
|
155
|
+
- `attr_config currency: "usd"`
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
# Override via ENV
|
|
159
|
+
STRIPE_CURRENCY=eur rails console
|
|
160
|
+
StripeConfig.currency #=> "eur"
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Local Overrides
|
|
164
|
+
|
|
165
|
+
```yaml
|
|
166
|
+
# config/stripe.local.yml (gitignored)
|
|
167
|
+
api_key: sk_test_local_override
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Testing
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
RSpec.describe PaymentService do
|
|
174
|
+
let(:config) do
|
|
175
|
+
StripeConfig.new(
|
|
176
|
+
api_key: "test_key",
|
|
177
|
+
webhook_secret: "test_secret",
|
|
178
|
+
currency: "eur"
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it "uses configured currency" do
|
|
183
|
+
service = described_class.new(config: config)
|
|
184
|
+
# ...
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Or with environment override
|
|
189
|
+
RSpec.describe "with config" do
|
|
190
|
+
around do |example|
|
|
191
|
+
with_env("STRIPE_CURRENCY" => "gbp") { example.run }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
it "uses environment value" do
|
|
195
|
+
expect(StripeConfig.new.currency).to eq("gbp")
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Rails Credentials
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
class SecretsConfig < ApplicationConfig
|
|
204
|
+
attr_config :secret_key_base,
|
|
205
|
+
:api_credentials
|
|
206
|
+
|
|
207
|
+
# Load from Rails credentials
|
|
208
|
+
def values
|
|
209
|
+
Rails.application.credentials.to_h
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
```
|
data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/rubanok.md
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# Rubanok
|
|
2
|
+
|
|
3
|
+
Transform controller params into database queries.
|
|
4
|
+
|
|
5
|
+
**GitHub**: https://github.com/palkan/rubanok
|
|
6
|
+
**Layer**: Presentation/Application boundary
|
|
7
|
+
|
|
8
|
+
## Contents
|
|
9
|
+
|
|
10
|
+
- Installation
|
|
11
|
+
- Basic Usage
|
|
12
|
+
- Param Mapping with `map`
|
|
13
|
+
- Matching with `match`
|
|
14
|
+
- Failure Strategy for Invalid Params
|
|
15
|
+
- Nested Params
|
|
16
|
+
- Sorting
|
|
17
|
+
- Pagination
|
|
18
|
+
- With Form Object
|
|
19
|
+
- Testing
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# Gemfile
|
|
25
|
+
gem "rubanok"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Basic Usage
|
|
29
|
+
|
|
30
|
+
### Define Processor
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
class PostsProcessor < Rubanok::Processor
|
|
34
|
+
# Simple equality match
|
|
35
|
+
map :status
|
|
36
|
+
|
|
37
|
+
# With transformation
|
|
38
|
+
map :author_id do |author_id:|
|
|
39
|
+
raw.where(author_id: author_id)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Search
|
|
43
|
+
map :q do |q:|
|
|
44
|
+
raw.where("title ILIKE ?", "%#{q}%")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Sorting
|
|
48
|
+
map :sort do |sort:|
|
|
49
|
+
field, dir = sort.split("_")
|
|
50
|
+
raw.order(field => dir)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Controller Integration
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
class PostsController < ApplicationController
|
|
59
|
+
def index
|
|
60
|
+
@posts = rubanok_process(Post.all, params)
|
|
61
|
+
# Or with explicit processor
|
|
62
|
+
@posts = rubanok_process(Post.all, params, with: PostsProcessor)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Param Mapping with `map`
|
|
68
|
+
|
|
69
|
+
### Simple Match
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
class PostsProcessor < Rubanok::Processor
|
|
73
|
+
# Maps params[:status] to where(status: value)
|
|
74
|
+
map :status
|
|
75
|
+
|
|
76
|
+
# With alias
|
|
77
|
+
map :state, to: :status
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Custom Mapping
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
class PostsProcessor < Rubanok::Processor
|
|
85
|
+
map :published do |published:|
|
|
86
|
+
if published == "true"
|
|
87
|
+
raw.where.not(published_at: nil)
|
|
88
|
+
else
|
|
89
|
+
raw.where(published_at: nil)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Multiple Params
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
class PostsProcessor < Rubanok::Processor
|
|
99
|
+
map :min_date, :max_date do |min_date: nil, max_date: nil|
|
|
100
|
+
scope = raw
|
|
101
|
+
scope = scope.where("created_at >= ?", min_date) if min_date
|
|
102
|
+
scope = scope.where("created_at <= ?", max_date) if max_date
|
|
103
|
+
scope
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Matching with `match`
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
class PostsProcessor < Rubanok::Processor
|
|
112
|
+
# Match specific values
|
|
113
|
+
match :order, ->(dir) { dir.in?(%w[asc desc]) } do |order:|
|
|
114
|
+
raw.order(created_at: order)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Match with type checking
|
|
118
|
+
match :page, ->(v) { v.to_i.positive? } do |page:|
|
|
119
|
+
raw.page(page.to_i)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Failure Strategy for Invalid Params
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
class PostsProcessor < Rubanok::Processor
|
|
128
|
+
# Raise on invalid params
|
|
129
|
+
fail_when :status, ->(v) { !Post.statuses.key?(v) }
|
|
130
|
+
|
|
131
|
+
# Or custom handling
|
|
132
|
+
match :status, ->(v) { Post.statuses.key?(v) } do |status:|
|
|
133
|
+
raw.where(status: status)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Nested Params
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
class PostsProcessor < Rubanok::Processor
|
|
142
|
+
nested :filter do
|
|
143
|
+
map :status
|
|
144
|
+
map :author_id
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Processes params[:filter][:status], params[:filter][:author_id]
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Sorting
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
class PostsProcessor < Rubanok::Processor
|
|
155
|
+
SORTABLE_FIELDS = %w[title created_at views_count].freeze
|
|
156
|
+
|
|
157
|
+
map :sort_by, :sort_order do |sort_by: "created_at", sort_order: "desc"|
|
|
158
|
+
field = SORTABLE_FIELDS.include?(sort_by) ? sort_by : "created_at"
|
|
159
|
+
dir = sort_order == "asc" ? :asc : :desc
|
|
160
|
+
raw.order(field => dir)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Pagination
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
class PostsProcessor < Rubanok::Processor
|
|
169
|
+
DEFAULT_PER_PAGE = 20
|
|
170
|
+
MAX_PER_PAGE = 100
|
|
171
|
+
|
|
172
|
+
map :page, :per_page do |page: 1, per_page: DEFAULT_PER_PAGE|
|
|
173
|
+
page = [page.to_i, 1].max
|
|
174
|
+
per_page = [[per_page.to_i, 1].max, MAX_PER_PAGE].min
|
|
175
|
+
|
|
176
|
+
raw.offset((page - 1) * per_page).limit(per_page)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## With Form Object
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
class PostsFilterForm
|
|
185
|
+
include ActiveModel::Model
|
|
186
|
+
include ActiveModel::Attributes
|
|
187
|
+
|
|
188
|
+
attribute :status, :string
|
|
189
|
+
attribute :author_id, :integer
|
|
190
|
+
attribute :q, :string
|
|
191
|
+
attribute :sort_by, :string, default: "created_at"
|
|
192
|
+
attribute :sort_order, :string, default: "desc"
|
|
193
|
+
|
|
194
|
+
validates :status, inclusion: { in: Post.statuses.keys }, allow_blank: true
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
class PostsController < ApplicationController
|
|
198
|
+
def index
|
|
199
|
+
@filter = PostsFilterForm.new(filter_params)
|
|
200
|
+
|
|
201
|
+
if @filter.valid?
|
|
202
|
+
@posts = rubanok_process(Post.all, @filter.attributes)
|
|
203
|
+
else
|
|
204
|
+
@posts = Post.none
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
def filter_params
|
|
211
|
+
params.fetch(:filter, {}).permit(:status, :author_id, :q, :sort_by, :sort_order)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Testing
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
RSpec.describe PostsProcessor do
|
|
220
|
+
let(:scope) { Post.all }
|
|
221
|
+
|
|
222
|
+
describe "status filter" do
|
|
223
|
+
it "filters by status" do
|
|
224
|
+
result = described_class.call(scope, status: "published")
|
|
225
|
+
|
|
226
|
+
expect(result.to_sql).to include("status")
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
describe "search" do
|
|
231
|
+
it "searches by title" do
|
|
232
|
+
result = described_class.call(scope, q: "ruby")
|
|
233
|
+
|
|
234
|
+
expect(result.to_sql).to include("ILIKE")
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
describe "sorting" do
|
|
239
|
+
it "sorts by field" do
|
|
240
|
+
result = described_class.call(scope, sort: "title_asc")
|
|
241
|
+
|
|
242
|
+
expect(result.to_sql).to include("ORDER BY")
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
```
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# ViewComponent
|
|
2
|
+
|
|
3
|
+
Framework for building reusable, testable view components.
|
|
4
|
+
|
|
5
|
+
**GitHub**: https://github.com/viewcomponent/view_component
|
|
6
|
+
**Layer**: Presentation
|
|
7
|
+
|
|
8
|
+
## Contents
|
|
9
|
+
|
|
10
|
+
- Installation
|
|
11
|
+
- Basic Usage
|
|
12
|
+
- Slots
|
|
13
|
+
- Collections
|
|
14
|
+
- Inline Components
|
|
15
|
+
- Stimulus Integration
|
|
16
|
+
- Previews
|
|
17
|
+
- Testing
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
# Gemfile
|
|
23
|
+
gem "view_component"
|
|
24
|
+
|
|
25
|
+
# Generate component
|
|
26
|
+
rails generate component UserAvatar user size
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Basic Usage
|
|
30
|
+
|
|
31
|
+
### Define Component
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
# app/components/user_avatar_component.rb
|
|
35
|
+
class UserAvatarComponent < ViewComponent::Base
|
|
36
|
+
def initialize(user:, size: :medium)
|
|
37
|
+
@user = user
|
|
38
|
+
@size = size
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def size_class
|
|
44
|
+
case @size
|
|
45
|
+
when :small then "w-8 h-8"
|
|
46
|
+
when :medium then "w-12 h-12"
|
|
47
|
+
when :large then "w-16 h-16"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```erb
|
|
54
|
+
<%# app/components/user_avatar_component.html.erb %>
|
|
55
|
+
<div class="avatar <%= size_class %>">
|
|
56
|
+
<% if @user.avatar.attached? %>
|
|
57
|
+
<%= image_tag @user.avatar, class: "rounded-full" %>
|
|
58
|
+
<% else %>
|
|
59
|
+
<span class="avatar-initials"><%= @user.initials %></span>
|
|
60
|
+
<% end %>
|
|
61
|
+
</div>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Render Component
|
|
65
|
+
|
|
66
|
+
```erb
|
|
67
|
+
<%= render(UserAvatarComponent.new(user: @user, size: :large)) %>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Slots
|
|
71
|
+
|
|
72
|
+
### Single Slot
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
class CardComponent < ViewComponent::Base
|
|
76
|
+
renders_one :header
|
|
77
|
+
renders_one :footer
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```erb
|
|
82
|
+
<%# Template %>
|
|
83
|
+
<div class="card">
|
|
84
|
+
<% if header? %>
|
|
85
|
+
<header><%= header %></header>
|
|
86
|
+
<% end %>
|
|
87
|
+
|
|
88
|
+
<div class="card-body"><%= content %></div>
|
|
89
|
+
|
|
90
|
+
<% if footer? %>
|
|
91
|
+
<footer><%= footer %></footer>
|
|
92
|
+
<% end %>
|
|
93
|
+
</div>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```erb
|
|
97
|
+
<%# Usage %>
|
|
98
|
+
<%= render(CardComponent.new) do |card| %>
|
|
99
|
+
<% card.with_header { "Title" } %>
|
|
100
|
+
<p>Body content</p>
|
|
101
|
+
<% card.with_footer { "Footer text" } %>
|
|
102
|
+
<% end %>
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Multiple Slots
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
class ListComponent < ViewComponent::Base
|
|
109
|
+
renders_many :items, ItemComponent
|
|
110
|
+
|
|
111
|
+
class ItemComponent < ViewComponent::Base
|
|
112
|
+
def initialize(title:)
|
|
113
|
+
@title = title
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Collections
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
class PostComponent < ViewComponent::Base
|
|
123
|
+
with_collection_parameter :post
|
|
124
|
+
|
|
125
|
+
def initialize(post:)
|
|
126
|
+
@post = post
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Render collection
|
|
131
|
+
<%= render(PostComponent.with_collection(@posts)) %>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Inline Components
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
class StatusBadgeComponent < ViewComponent::Base
|
|
138
|
+
def initialize(status:)
|
|
139
|
+
@status = status
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def call
|
|
143
|
+
content_tag :span, @status.titleize, class: "badge #{badge_class}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def badge_class
|
|
149
|
+
case @status.to_sym
|
|
150
|
+
when :active then "badge-success"
|
|
151
|
+
when :pending then "badge-warning"
|
|
152
|
+
else "badge-secondary"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Stimulus Integration
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
class DropdownComponent < ViewComponent::Base
|
|
162
|
+
def initialize(label:)
|
|
163
|
+
@label = label
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
```erb
|
|
169
|
+
<div data-controller="dropdown">
|
|
170
|
+
<button data-action="dropdown#toggle"><%= @label %></button>
|
|
171
|
+
<div data-dropdown-target="menu" class="hidden">
|
|
172
|
+
<%= content %>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Previews
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
# test/components/previews/user_avatar_component_preview.rb
|
|
181
|
+
class UserAvatarComponentPreview < ViewComponent::Preview
|
|
182
|
+
def small
|
|
183
|
+
render(UserAvatarComponent.new(user: User.first, size: :small))
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def medium
|
|
187
|
+
render(UserAvatarComponent.new(user: User.first, size: :medium))
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def large
|
|
191
|
+
render(UserAvatarComponent.new(user: User.first, size: :large))
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Access at: `/rails/view_components/user_avatar_component/small`
|
|
197
|
+
|
|
198
|
+
## Testing
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
RSpec.describe UserAvatarComponent, type: :component do
|
|
202
|
+
it "renders avatar for user with image" do
|
|
203
|
+
user = create(:user, :with_avatar)
|
|
204
|
+
render_inline(described_class.new(user: user))
|
|
205
|
+
|
|
206
|
+
expect(page).to have_css("img.rounded-full")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
it "renders initials without image" do
|
|
210
|
+
user = build(:user, name: "John Doe")
|
|
211
|
+
render_inline(described_class.new(user: user))
|
|
212
|
+
|
|
213
|
+
expect(page).to have_css(".avatar-initials", text: "JD")
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it "applies size class" do
|
|
217
|
+
user = build(:user)
|
|
218
|
+
render_inline(described_class.new(user: user, size: :large))
|
|
219
|
+
|
|
220
|
+
expect(page).to have_css(".w-16.h-16")
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
```
|