well_formed 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/.rspec +3 -0
- data/.rubocop.yml +12 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +869 -0
- data/Rakefile +36 -0
- data/Steepfile +17 -0
- data/gems/well_formed-pundit/.rspec +3 -0
- data/gems/well_formed-pundit/Gemfile +13 -0
- data/gems/well_formed-pundit/README.md +73 -0
- data/gems/well_formed-pundit/Rakefile +12 -0
- data/gems/well_formed-pundit/lib/well_formed/pundit/version.rb +7 -0
- data/gems/well_formed-pundit/lib/well_formed/pundit.rb +25 -0
- data/gems/well_formed-pundit/lib/well_formed-pundit.rb +8 -0
- data/gems/well_formed-pundit/spec/spec_helper.rb +13 -0
- data/gems/well_formed-pundit/spec/well_formed/pundit_integration_spec.rb +101 -0
- data/gems/well_formed-pundit/spec/well_formed/pundit_spec.rb +80 -0
- data/gems/well_formed-pundit/well_formed-pundit.gemspec +28 -0
- data/lib/generators/resource_form_generator.rb +26 -0
- data/lib/generators/templates/form.rb.tt +28 -0
- data/lib/well_formed/action_form.rb +7 -0
- data/lib/well_formed/attribute_assignment.rb +42 -0
- data/lib/well_formed/collections.rb +114 -0
- data/lib/well_formed/errors.rb +16 -0
- data/lib/well_formed/initializer.rb +31 -0
- data/lib/well_formed/nested_attributes.rb +194 -0
- data/lib/well_formed/nested_form.rb +14 -0
- data/lib/well_formed/performer.rb +57 -0
- data/lib/well_formed/persistence.rb +61 -0
- data/lib/well_formed/railtie.rb +9 -0
- data/lib/well_formed/record_identity.rb +32 -0
- data/lib/well_formed/resource_form.rb +7 -0
- data/lib/well_formed/simple_action.rb +14 -0
- data/lib/well_formed/simple_nested_form.rb +12 -0
- data/lib/well_formed/simple_resource.rb +14 -0
- data/lib/well_formed/simple_struct.rb +29 -0
- data/lib/well_formed/struct.rb +7 -0
- data/lib/well_formed/transactional.rb +38 -0
- data/lib/well_formed/translations.rb +30 -0
- data/lib/well_formed/version.rb +5 -0
- data/lib/well_formed/with_user.rb +43 -0
- data/lib/well_formed.rb +38 -0
- data/rbs_collection.yaml +19 -0
- data/sig/well_formed.rbs +129 -0
- metadata +105 -0
data/README.md
ADDED
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
# WellFormed
|
|
2
|
+
|
|
3
|
+
WellFormed is a lightweight form object library for Rails. Inherit from `WellFormed::ResourceForm` for standard create/update flows, or `WellFormed::ActionForm` for custom actions that don't persist a resource — both accept a resource, a user, and a params hash, with full `ActiveModel::Model` and `ActiveModel::Attributes` support.
|
|
4
|
+
|
|
5
|
+
Form objects are compatible with `form_with` and Rails view helpers anywhere an ActiveRecord model would be accepted.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
|
|
10
|
+
|
|
11
|
+
Install the gem and add to the application's Gemfile by executing:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
class CreateArticleForm < WellFormed::ResourceForm
|
|
27
|
+
resource_alias :article
|
|
28
|
+
|
|
29
|
+
attribute :title, :string
|
|
30
|
+
attribute :body, :string
|
|
31
|
+
|
|
32
|
+
validates :title, presence: true
|
|
33
|
+
validates :body, presence: true
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# app/controllers/articles_controller.rb
|
|
39
|
+
def create
|
|
40
|
+
@form = CreateArticleForm.new(Article.new, current_user, article_params)
|
|
41
|
+
if (@article = @form.submit)
|
|
42
|
+
redirect_to @article
|
|
43
|
+
else
|
|
44
|
+
render :new, status: :unprocessable_entity
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# app/controllers/api/articles_controller.rb
|
|
51
|
+
def create
|
|
52
|
+
@form = CreateArticleForm.new(Article.new, current_user, article_params)
|
|
53
|
+
if @form.save
|
|
54
|
+
render json: @form.article, status: :created
|
|
55
|
+
else
|
|
56
|
+
render json: {errors: @form.errors.messages}, status: :unprocessable_content
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
class PublishArticleForm < WellFormed::ActionForm
|
|
63
|
+
resource_alias :article
|
|
64
|
+
|
|
65
|
+
attribute :notify_subscribers, :boolean, default: false
|
|
66
|
+
|
|
67
|
+
def perform
|
|
68
|
+
article.publish!
|
|
69
|
+
PublishMailer.notify(article).deliver_later if notify_subscribers
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# app/controllers/articles_controller.rb
|
|
76
|
+
def publish
|
|
77
|
+
@form = PublishArticleForm.new(@article, current_user, publish_params)
|
|
78
|
+
if @form.submit
|
|
79
|
+
redirect_to @article
|
|
80
|
+
else
|
|
81
|
+
render :edit, status: :unprocessable_entity
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Constructor
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
form = CreateArticleForm.new(article, current_user, { title: "Hello", body: "World" })
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
| Argument | Description |
|
|
93
|
+
|----------|-------------|
|
|
94
|
+
| `resource` | The ActiveRecord object (or any object) the form wraps |
|
|
95
|
+
| `user` | The user performing the action |
|
|
96
|
+
| `params` | A hash of form values to assign to declared attributes (optional, defaults to `{}`) |
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
form.resource # => article
|
|
100
|
+
form.user # => current_user
|
|
101
|
+
form.title # => "Hello"
|
|
102
|
+
|
|
103
|
+
form.valid? # => true / false (ActiveModel validations)
|
|
104
|
+
form.errors # => ActiveModel::Errors
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Overriding the constructor
|
|
108
|
+
|
|
109
|
+
Subclasses can define their own `initialize` — just call `super` to let the base set up `resource`, `user`, and attribute assignment. A common pattern is **nested resource creation**: the form accepts the parent as its first argument and builds the child record internally, keeping the controller clean:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
module Articles
|
|
113
|
+
class CreatePostForm < WellFormed::ResourceForm
|
|
114
|
+
resource_alias :post
|
|
115
|
+
|
|
116
|
+
attribute :title, :string
|
|
117
|
+
attribute :body, :string
|
|
118
|
+
|
|
119
|
+
validates :title, presence: true
|
|
120
|
+
|
|
121
|
+
def initialize(article, user, params = {})
|
|
122
|
+
super(article.posts.build, user, params)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
# Controller
|
|
130
|
+
def create
|
|
131
|
+
@form = Articles::CreatePostForm.new(@article, current_user, post_params)
|
|
132
|
+
if @form.save
|
|
133
|
+
redirect_to @article
|
|
134
|
+
else
|
|
135
|
+
render :new, status: :unprocessable_entity
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`article.posts.build` scopes the new record to the parent so the association is set before `save` is called.
|
|
141
|
+
|
|
142
|
+
### Translations — `resource_alias`
|
|
143
|
+
|
|
144
|
+
Call `resource_alias` to declare what the wrapped resource represents. This does two things:
|
|
145
|
+
|
|
146
|
+
1. Adds a reader method on the form that aliases `resource` using the given name
|
|
147
|
+
2. Sets `model_name` so that ActiveModel error messages use the correct I18n translation keys
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
class CreateArticleForm < WellFormed::ResourceForm
|
|
151
|
+
resource_alias :article
|
|
152
|
+
|
|
153
|
+
attribute :title, :string
|
|
154
|
+
validates :title, presence: true
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
form = CreateArticleForm.new(article, current_user, {})
|
|
158
|
+
form.article # => same as form.resource
|
|
159
|
+
form.errors.full_messages # => ["Title can't be blank"] (keyed under "article.*")
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`resource_alias` accepts a symbol, a snake_case or CamelCase string, or an ActiveModel class:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
resource_alias :article # symbol
|
|
166
|
+
resource_alias "article_comment" # snake_case string
|
|
167
|
+
resource_alias Article # class — borrows Article.model_name directly
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Persistence — `save` and `submit`
|
|
171
|
+
|
|
172
|
+
The default `save` method:
|
|
173
|
+
|
|
174
|
+
1. Runs validations — returns `false` immediately if invalid
|
|
175
|
+
2. Assigns only form attributes that the resource has a corresponding setter for
|
|
176
|
+
3. Calls `save` on the resource and returns its result (`true` / `false`)
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
form = CreateArticleForm.new(article, current_user, { title: "Hello", body: "World" })
|
|
180
|
+
form.save # => true / false
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`submit` is a convenience wrapper around `save` that returns the resource on success or `false` on failure, making controller code more concise:
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
if (article = form.submit)
|
|
187
|
+
render json: article.as_json, status: :created
|
|
188
|
+
else
|
|
189
|
+
render json: { errors: form.errors.messages }, status: :unprocessable_entity
|
|
190
|
+
end
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Use `save!` or `submit!` when you prefer raising over checking a return value — both raise `WellFormed::RecordInvalid` on failure:
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
form.save! # raises WellFormed::RecordInvalid if invalid or resource.save fails
|
|
197
|
+
form.submit! # raises WellFormed::RecordInvalid if invalid or resource.save fails
|
|
198
|
+
# (submit! also returns the resource on success)
|
|
199
|
+
|
|
200
|
+
rescue WellFormed::RecordInvalid => e
|
|
201
|
+
e.record # => the form object
|
|
202
|
+
e.message # => "Validation failed: Title can't be blank"
|
|
203
|
+
e.record.errors # => ActiveModel::Errors
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
For side effects around the save — sending emails, creating audit logs, etc. — use `before_save`, `after_save`, and `after_save_commit` callbacks. For forms that need fully custom persistence logic, consider `WellFormed::Struct` (plain Ruby object resources) or `WellFormed::ActionForm` (no resource persistence at all).
|
|
208
|
+
|
|
209
|
+
#### Unmatched attributes
|
|
210
|
+
|
|
211
|
+
Form attributes with no matching setter on the resource are silently skipped when `save` is called — they are filtered out before `assign_attributes` is called, so Rails never raises `ActiveModel::UnknownAttributeError`.
|
|
212
|
+
|
|
213
|
+
This is the normal case for virtual attributes like `agree_to_terms` or `current_password`, which exist on the form for validation or logic but have no corresponding column on the model — they are validated and accessible on the form as normal, without being assigned to the resource. Alternatively, declare the attribute on the form with `attr_writer` (or `attr_accessor`) instead of `attribute` — the value is still assigned from params, but won't be forwarded to the resource on save.
|
|
214
|
+
|
|
215
|
+
Use `unmatched_attributes` to opt in to a warning or error at the form level instead:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
class CreateArticleForm < WellFormed::ResourceForm
|
|
219
|
+
unmatched_attributes :warn # prints a warning to stderr
|
|
220
|
+
# unmatched_attributes :raise # raises WellFormed::UnmatchedAttributesError
|
|
221
|
+
# unmatched_attributes :ignore # default — silent skip
|
|
222
|
+
end
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`:warn` and `:raise` are useful during development to catch typos or attribute drift between the form and its resource.
|
|
226
|
+
|
|
227
|
+
#### Model validation errors — `merge_model_errors`
|
|
228
|
+
|
|
229
|
+
By default, `save` only runs form-level validations. If `resource.save` fails due to a model-level validation that the form does not replicate, `save` returns `false` with a generic `:base` error (`"could not be saved"`) so that `errors` is never silently empty.
|
|
230
|
+
|
|
231
|
+
When you want model errors surfaced on the form — for example in an API that uses `Halitosis::ErrorsSerializer` — declare `merge_model_errors` in the form class. The form will then copy `resource.errors` onto itself whenever `resource.save` returns `false`:
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
class Api::CreateUserForm < WellFormed::ResourceForm
|
|
235
|
+
resource_alias :user
|
|
236
|
+
|
|
237
|
+
merge_model_errors # copies resource.errors onto the form on save failure
|
|
238
|
+
|
|
239
|
+
attribute :name, :string
|
|
240
|
+
attribute :email, :string
|
|
241
|
+
|
|
242
|
+
validates :name, presence: true
|
|
243
|
+
validates :email, presence: true
|
|
244
|
+
# No format validation here — the User model owns that rule
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
```ruby
|
|
249
|
+
# User model
|
|
250
|
+
class User < ApplicationRecord
|
|
251
|
+
validates :email, format: { with: /\A[^@\s]+@[^@\s]+\z/, message: "is invalid" }
|
|
252
|
+
end
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Submitting `email: "not-an-email"` passes form validation but fails at the model. With `merge_model_errors`, the error is copied back:
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
form.save # => false
|
|
259
|
+
form.errors[:email] # => ["is invalid"]
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
This integrates cleanly with Halitosis error serialisation:
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
render json: Halitosis::ErrorsSerializer.new(@form), status: :unprocessable_content
|
|
266
|
+
# { "errors": [{ "code": "email_invalid", "detail": "Email is invalid",
|
|
267
|
+
# "source": { "pointer": "/user/email" } }] }
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
#### Callbacks — `before_validation` and `after_validation`
|
|
271
|
+
|
|
272
|
+
Use `before_validation` to normalise or coerce attribute values before the validation run, and `after_validation` to act on the errors that were just collected — or to transform attribute values before they are assigned to the resource.
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
class CreateArticleForm < WellFormed::ResourceForm
|
|
276
|
+
attribute :title, :string
|
|
277
|
+
attribute :tag_list, :string
|
|
278
|
+
|
|
279
|
+
validates :title, presence: true
|
|
280
|
+
|
|
281
|
+
before_validation :normalise_title
|
|
282
|
+
after_validation :log_errors
|
|
283
|
+
|
|
284
|
+
private
|
|
285
|
+
|
|
286
|
+
def normalise_title
|
|
287
|
+
self.title = title&.strip&.downcase
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def log_errors
|
|
291
|
+
Rails.logger.warn(errors.full_messages) if errors.any?
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Blocks are also accepted:
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
before_validation { self.title = title&.strip }
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
To transform a form attribute before it reaches the resource, use `after_validation` — it runs after the validation run and before attributes are assigned:
|
|
303
|
+
|
|
304
|
+
```ruby
|
|
305
|
+
after_validation { self.title = title&.strip&.downcase }
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
#### Callbacks — `before_save` and `after_save`
|
|
309
|
+
|
|
310
|
+
Use `before_save` to run logic after attributes have been assigned to the resource but before it is saved. Use `after_save` to run logic once the resource has been saved.
|
|
311
|
+
|
|
312
|
+
```ruby
|
|
313
|
+
class CreateArticleForm < WellFormed::ResourceForm
|
|
314
|
+
attribute :title, :string
|
|
315
|
+
|
|
316
|
+
before_save :set_created_by
|
|
317
|
+
after_save :notify_subscribers
|
|
318
|
+
|
|
319
|
+
private
|
|
320
|
+
|
|
321
|
+
def set_created_by
|
|
322
|
+
resource.created_by = user
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def notify_subscribers
|
|
326
|
+
NotificationMailer.new_article(resource).deliver_later
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Blocks are also accepted:
|
|
332
|
+
|
|
333
|
+
```ruby
|
|
334
|
+
before_save { resource.created_by = user }
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
A `before_save` callback can halt the save by calling `throw :abort`, in which case `save` returns `false` and `resource.save` is never called:
|
|
338
|
+
|
|
339
|
+
```ruby
|
|
340
|
+
before_save { throw :abort unless user.can_publish? }
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
#### Commit callbacks — `after_save_commit`
|
|
344
|
+
|
|
345
|
+
`after_save_commit` registers a callback that fires after all surrounding database transactions have committed — safe for side effects like sending emails or enqueuing background jobs that must not run if the transaction rolls back.
|
|
346
|
+
|
|
347
|
+
Internally, `after_save_commit` uses `ActiveRecord.after_all_transactions_commit` — so if `save` is called inside a larger controller-level transaction, the callbacks wait for the outermost transaction to commit before firing.
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
class CreateArticleForm < WellFormed::ResourceForm
|
|
351
|
+
after_save_commit :notify_subscribers
|
|
352
|
+
|
|
353
|
+
private
|
|
354
|
+
|
|
355
|
+
def notify_subscribers
|
|
356
|
+
NotificationMailer.new_article(resource).deliver_later
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
#### Transactions — `save_within_transaction`
|
|
362
|
+
|
|
363
|
+
Call `save_within_transaction` to wrap the entire save (including all callbacks) in a database transaction. If `resource.save` returns `false`, or any callback raises, the transaction is rolled back automatically:
|
|
364
|
+
|
|
365
|
+
```ruby
|
|
366
|
+
class CreateArticleForm < WellFormed::ResourceForm
|
|
367
|
+
resource_alias :article
|
|
368
|
+
|
|
369
|
+
attribute :title, :string
|
|
370
|
+
attribute :body, :string
|
|
371
|
+
|
|
372
|
+
validates :title, presence: true
|
|
373
|
+
|
|
374
|
+
save_within_transaction
|
|
375
|
+
|
|
376
|
+
after_save :create_audit_log # runs inside the transaction
|
|
377
|
+
after_save_commit :notify_subscribers # runs after the transaction commits
|
|
378
|
+
|
|
379
|
+
private
|
|
380
|
+
|
|
381
|
+
def create_audit_log
|
|
382
|
+
AuditLog.create!(action: :created, record: article, user: user)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def notify_subscribers
|
|
386
|
+
NotificationMailer.new_article(article).deliver_later
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
If `AuditLog.create!` raises, the article save is rolled back with it. `after_save_commit` callbacks only fire once the transaction successfully commits.
|
|
392
|
+
|
|
393
|
+
## Action forms
|
|
394
|
+
|
|
395
|
+
For custom actions that don't map to a standard create/update — publishing, archiving, sending a notification — inherit from `WellFormed::ActionForm`. These are identical to regular forms except that attributes are not assigned to the resource, `save` is never called, and there is no default `save` implementation. You define `perform` instead.
|
|
396
|
+
|
|
397
|
+
```ruby
|
|
398
|
+
class PublishArticleForm < WellFormed::ActionForm
|
|
399
|
+
resource_alias :article
|
|
400
|
+
|
|
401
|
+
attribute :notify_subscribers, :boolean, default: false
|
|
402
|
+
|
|
403
|
+
validates :article, presence: true
|
|
404
|
+
|
|
405
|
+
def perform
|
|
406
|
+
article.publish!
|
|
407
|
+
PublishMailer.notify(article).deliver_later if notify_subscribers
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Call `submit` to run validations and then `perform`, or `submit!` to raise on failure:
|
|
413
|
+
|
|
414
|
+
```ruby
|
|
415
|
+
form = PublishArticleForm.new(article, current_user, { notify_subscribers: true })
|
|
416
|
+
form.submit # => true if perform ran, false if invalid or halted by before_perform
|
|
417
|
+
# if halted with no errors, a generic base error ("could not be performed") is added
|
|
418
|
+
form.submit! # raises WellFormed::RecordInvalid if invalid or before_perform halts
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
### Callbacks — `before_perform` and `after_perform`
|
|
422
|
+
|
|
423
|
+
`before_perform` and `after_perform` work the same way as `before_save`/`after_save` in regular forms:
|
|
424
|
+
|
|
425
|
+
```ruby
|
|
426
|
+
class PublishArticleForm < WellFormed::ActionForm
|
|
427
|
+
before_perform :check_permissions
|
|
428
|
+
after_perform :audit_log
|
|
429
|
+
|
|
430
|
+
def perform
|
|
431
|
+
article.publish!
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def check_permissions
|
|
435
|
+
throw :abort unless user.can_publish?(article)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def audit_log
|
|
439
|
+
AuditLog.record(user, :published, article)
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
Blocks are also accepted:
|
|
445
|
+
|
|
446
|
+
```ruby
|
|
447
|
+
before_perform { throw :abort unless user.can_publish?(article) }
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
## Nested attributes
|
|
451
|
+
|
|
452
|
+
Nested form objects are built in to `WellFormed::ResourceForm`, `WellFormed::ActionForm`, and `WellFormed::Struct`.
|
|
453
|
+
|
|
454
|
+
```ruby
|
|
455
|
+
class CreateOrderForm < WellFormed::ResourceForm
|
|
456
|
+
resource_alias :order
|
|
457
|
+
|
|
458
|
+
attribute :customer_name, :string
|
|
459
|
+
validates :customer_name, presence: true
|
|
460
|
+
|
|
461
|
+
nested_attributes_for :line_items do
|
|
462
|
+
attribute :name, :string
|
|
463
|
+
attribute :quantity, :integer
|
|
464
|
+
|
|
465
|
+
validates :name, presence: true
|
|
466
|
+
validates :quantity, numericality: { greater_than: 0 }
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
nested_attribute_for :billing_address do
|
|
470
|
+
attribute :street, :string
|
|
471
|
+
attribute :city, :string
|
|
472
|
+
attribute :postcode, :string
|
|
473
|
+
|
|
474
|
+
validates :street, presence: true
|
|
475
|
+
validates :city, presence: true
|
|
476
|
+
validates :postcode, presence: true
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
### Macros
|
|
482
|
+
|
|
483
|
+
| Macro | Wraps |
|
|
484
|
+
|-------|-------|
|
|
485
|
+
| `nested_attributes_for :name` | A **collection** of nested forms (e.g. `has_many`) |
|
|
486
|
+
| `nested_attribute_for :name` | A **single** nested form (e.g. `has_one` / `belongs_to`) |
|
|
487
|
+
|
|
488
|
+
Each macro accepts either an inline block (shown above) or an explicit form class:
|
|
489
|
+
|
|
490
|
+
```ruby
|
|
491
|
+
nested_attributes_for :line_items, LineItemForm
|
|
492
|
+
nested_attribute_for :billing_address, BillingAddressForm
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
Passing both raises `ArgumentError`.
|
|
496
|
+
|
|
497
|
+
### Setters
|
|
498
|
+
|
|
499
|
+
Each macro defines two setters — both do the same thing and can be used interchangeably:
|
|
500
|
+
|
|
501
|
+
| Setter | Use case |
|
|
502
|
+
|--------|----------|
|
|
503
|
+
| `name_attributes=` | Rails `fields_for` / `simple_fields_for` (HTML form params with `_attributes` suffix) |
|
|
504
|
+
| `name=` | API-style params without the suffix |
|
|
505
|
+
|
|
506
|
+
The underlying resource receives `name_attributes=` immediately when either setter is called, so `accepts_nested_attributes_for` on the model works as normal.
|
|
507
|
+
|
|
508
|
+
### Validations and errors
|
|
509
|
+
|
|
510
|
+
Nested forms are validated automatically as part of the parent form's `valid?` call. Errors from nested forms are promoted to the parent using structured keys:
|
|
511
|
+
|
|
512
|
+
- **Collection** — `"line_items[0].name"`, `"line_items[1].quantity"`, …
|
|
513
|
+
- **Singular** — `"billing_address.street"`, `"billing_address.city"`, …
|
|
514
|
+
|
|
515
|
+
### Controller integration
|
|
516
|
+
|
|
517
|
+
Strong params for HTML forms use the `_attributes` suffix:
|
|
518
|
+
|
|
519
|
+
```ruby
|
|
520
|
+
def order_params
|
|
521
|
+
params.require(:order).permit(
|
|
522
|
+
:customer_name,
|
|
523
|
+
line_items_attributes: [:id, :name, :quantity, :_destroy],
|
|
524
|
+
billing_address_attributes: [:street, :city, :postcode]
|
|
525
|
+
)
|
|
526
|
+
end
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
For API endpoints that send params without the suffix, use plain nested keys instead:
|
|
530
|
+
|
|
531
|
+
```ruby
|
|
532
|
+
def order_params
|
|
533
|
+
params.require(:order).permit(
|
|
534
|
+
:customer_name,
|
|
535
|
+
line_items: [:name, :quantity],
|
|
536
|
+
billing_address: [:street, :city, :postcode]
|
|
537
|
+
)
|
|
538
|
+
end
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
### Initialising the form with pre-built nested records
|
|
542
|
+
|
|
543
|
+
When rendering a new-record form, build the nested records on the resource before passing it to the form so that `fields_for` / `simple_fields_for` has objects to iterate over:
|
|
544
|
+
|
|
545
|
+
```ruby
|
|
546
|
+
def new
|
|
547
|
+
order = Order.new
|
|
548
|
+
order.line_items.build # collection — build at least one blank item
|
|
549
|
+
order.build_billing_address # singular
|
|
550
|
+
|
|
551
|
+
@form = CreateOrderForm.new(order, current_user)
|
|
552
|
+
end
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
```erb
|
|
556
|
+
<%= form_with model: @form, url: orders_path do |f| %>
|
|
557
|
+
<%= f.text_field :customer_name %>
|
|
558
|
+
|
|
559
|
+
<%= f.fields_for :line_items do |lf| %>
|
|
560
|
+
<%= lf.text_field :name %>
|
|
561
|
+
<%= lf.number_field :quantity %>
|
|
562
|
+
<% end %>
|
|
563
|
+
|
|
564
|
+
<%= f.fields_for :billing_address do |af| %>
|
|
565
|
+
<%= af.text_field :street %>
|
|
566
|
+
<%= af.text_field :city %>
|
|
567
|
+
<%= af.text_field :postcode %>
|
|
568
|
+
<% end %>
|
|
569
|
+
|
|
570
|
+
<%= f.submit %>
|
|
571
|
+
<% end %>
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
### SimpleForm support
|
|
575
|
+
|
|
576
|
+
Nested form objects returned by `nested_attributes_for` / `nested_attribute_for` are compatible with [simple_form](https://github.com/heartcombo/simple_form). The anonymous class created by an inline block is given a synthetic `model_name` so that SimpleForm can infer input names and error wrappers correctly.
|
|
577
|
+
|
|
578
|
+
Use `simple_fields_for` in place of `fields_for` and `f.input` in place of the individual field helpers:
|
|
579
|
+
|
|
580
|
+
```erb
|
|
581
|
+
<%= simple_form_for @form, url: orders_path do |f| %>
|
|
582
|
+
<%= f.input :customer_name %>
|
|
583
|
+
|
|
584
|
+
<%= f.simple_fields_for :line_items do |lf| %>
|
|
585
|
+
<%= lf.input :name %>
|
|
586
|
+
<%= lf.input :quantity %>
|
|
587
|
+
<% end %>
|
|
588
|
+
|
|
589
|
+
<%= f.simple_fields_for :billing_address do |af| %>
|
|
590
|
+
<%= af.input :street %>
|
|
591
|
+
<%= af.input :city %>
|
|
592
|
+
<%= af.input :postcode %>
|
|
593
|
+
<% end %>
|
|
594
|
+
|
|
595
|
+
<%= f.submit %>
|
|
596
|
+
<% end %>
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
Validation errors on nested fields are promoted to the parent form with structured keys (e.g. `"line_items[0].name"`), which SimpleForm resolves back to the correct field and renders inline error messages automatically.
|
|
600
|
+
|
|
601
|
+
## Collections
|
|
602
|
+
|
|
603
|
+
Use `collection_for` to declare collection-backed select fields on your form, with optional inclusion validation.
|
|
604
|
+
|
|
605
|
+
```ruby
|
|
606
|
+
class CreatePostForm < WellFormed::ResourceForm
|
|
607
|
+
resource_alias :post
|
|
608
|
+
|
|
609
|
+
attribute :user_id, :integer
|
|
610
|
+
|
|
611
|
+
collection_for :user_id, validate: true do
|
|
612
|
+
User.order(:name)
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
`collection_for` generates a `collection_for_<name>` instance method that returns an ActiveRecord relation. The block is evaluated in the context of the form instance, so `user`, `resource`, and any other instance methods are available — useful for scoping:
|
|
618
|
+
|
|
619
|
+
```ruby
|
|
620
|
+
collection_for :user_id do
|
|
621
|
+
User.where(organisation: resource.organisation)
|
|
622
|
+
end
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
### Validation
|
|
626
|
+
|
|
627
|
+
Pass `validate: true` to automatically validate that the submitted value exists within the collection, matched by `:id`:
|
|
628
|
+
|
|
629
|
+
```ruby
|
|
630
|
+
collection_for :user_id, validate: true do
|
|
631
|
+
User.order(:name)
|
|
632
|
+
end
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
Validation uses `collection.where(id: value).pluck(:id)` — a single scoped query rather than loading all records into memory. Blank values are always accepted (`allow_blank: true`); pair with a `presence` validator when the field is required.
|
|
636
|
+
|
|
637
|
+
To match on a field other than `:id`, pass the field name:
|
|
638
|
+
|
|
639
|
+
```ruby
|
|
640
|
+
collection_for :status, validate: :code do
|
|
641
|
+
Status.active
|
|
642
|
+
end
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
### Code-to-value resolution (`resolves_to:`)
|
|
646
|
+
|
|
647
|
+
Use `resolves_to:` when the form accepts one representation of a value (e.g. a code string or an integer id) but the resource stores a different one. After validation, the attribute is automatically replaced with the resolved value via a `validate` callback.
|
|
648
|
+
|
|
649
|
+
`resolves_to:` requires `validate:` to be a Symbol — the field used to look up the record. If the lookup fails, an `:inclusion` error is added and no transformation is applied.
|
|
650
|
+
|
|
651
|
+
#### Code → id
|
|
652
|
+
|
|
653
|
+
The form accepts a code string; the resource stores the integer id:
|
|
654
|
+
|
|
655
|
+
```ruby
|
|
656
|
+
class CreatePostForm < WellFormed::ResourceForm
|
|
657
|
+
resource_alias :post
|
|
658
|
+
|
|
659
|
+
attribute :user_id # untyped — accepts a code string initially
|
|
660
|
+
|
|
661
|
+
collection_for :user_id, validate: :code, resolves_to: :id do
|
|
662
|
+
User.all
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# Submitting "ALICE" resolves to user.id and saves that integer
|
|
667
|
+
form = CreatePostForm.new(Post.new, current_user, { user_id: "ALICE" })
|
|
668
|
+
form.save # post.user_id == 42
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
`resolves_to: true` is shorthand for `resolves_to: :id`.
|
|
672
|
+
|
|
673
|
+
#### Id → code
|
|
674
|
+
|
|
675
|
+
The form accepts an integer id; the resource stores the code string:
|
|
676
|
+
|
|
677
|
+
```ruby
|
|
678
|
+
class CreatePostForm < WellFormed::ResourceForm
|
|
679
|
+
resource_alias :post
|
|
680
|
+
|
|
681
|
+
attribute :user_code # untyped — accepts an id initially
|
|
682
|
+
|
|
683
|
+
collection_for :user_code, validate: :id, resolves_to: :code do
|
|
684
|
+
User.all
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
# Submitting 42 resolves to user.code and saves that string
|
|
689
|
+
form = CreatePostForm.new(Post.new, current_user, { user_code: 42 })
|
|
690
|
+
form.save # post.user_code == "ALICE"
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
#### Edit forms
|
|
694
|
+
|
|
695
|
+
For both directions, `resource_defaults` is automatically overridden to reverse-populate the attribute with the *input* representation when loading an existing record — so the form pre-fills with the value the user expects to see and edit:
|
|
696
|
+
|
|
697
|
+
```ruby
|
|
698
|
+
# Code → id form: post.user_id is 42, form pre-fills user_id with "ALICE"
|
|
699
|
+
form = CreatePostForm.new(existing_post, current_user)
|
|
700
|
+
form.user_id # => "ALICE"
|
|
701
|
+
|
|
702
|
+
# Id → code form: post.user_code is "ALICE", form pre-fills user_code with 42
|
|
703
|
+
form = CreatePostForm.new(existing_post, current_user)
|
|
704
|
+
form.user_code # => 42
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
### View integration
|
|
708
|
+
|
|
709
|
+
Use `collection_for_<name>` directly in the view to build the select:
|
|
710
|
+
|
|
711
|
+
```erb
|
|
712
|
+
<%= f.collection_select :user_id, @form.collection_for_user_id, :id, :name, { include_blank: true } %>
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
### SimpleForm integration
|
|
716
|
+
|
|
717
|
+
With SimpleForm, pass the collection explicitly via the `collection:` option:
|
|
718
|
+
|
|
719
|
+
```erb
|
|
720
|
+
<%= f.input :user_id, collection: @form.collection_for_user_id %>
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
Alternatively, define a custom SimpleForm input that auto-discovers `collection_for_<name>` on the form object, so the view needs no explicit collection reference:
|
|
724
|
+
|
|
725
|
+
```ruby
|
|
726
|
+
# app/inputs/collection_for_input.rb
|
|
727
|
+
class CollectionForInput < SimpleForm::Inputs::CollectionSelectInput
|
|
728
|
+
def collection
|
|
729
|
+
if object.respond_to?(:"collection_for_#{attribute_name}")
|
|
730
|
+
@collection ||= object.public_send(:"collection_for_#{attribute_name}")
|
|
731
|
+
else
|
|
732
|
+
super
|
|
733
|
+
end
|
|
734
|
+
end
|
|
735
|
+
end
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
Then declare the field with `as: :collection_for`:
|
|
739
|
+
|
|
740
|
+
```erb
|
|
741
|
+
<%= f.input :user_id, as: :collection_for %>
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
## PORO support
|
|
745
|
+
|
|
746
|
+
When the resource is a plain Ruby object that does not respond to `save` — a value object, a service-layer struct, an API client payload — inherit from `WellFormed::Struct` instead. It has the same interface as `WellFormed::ResourceForm` but replaces the default `save` behaviour with a `perform` method you define yourself.
|
|
747
|
+
|
|
748
|
+
Form attributes are still auto-assigned to the resource (via individual setters), and `before_save`/`after_save` callbacks still work. The AR-specific helpers `after_save_commit` and `save_within_transaction` are not available.
|
|
749
|
+
|
|
750
|
+
```ruby
|
|
751
|
+
class CreateInvoiceForm < WellFormed::Struct
|
|
752
|
+
resource_alias :invoice
|
|
753
|
+
|
|
754
|
+
attribute :recipient_email, :string
|
|
755
|
+
attribute :amount_cents, :integer
|
|
756
|
+
|
|
757
|
+
validates :recipient_email, presence: true
|
|
758
|
+
validates :amount_cents, numericality: { greater_than: 0 }
|
|
759
|
+
|
|
760
|
+
after_validation :normalise_email
|
|
761
|
+
after_save :log_issuance
|
|
762
|
+
|
|
763
|
+
private
|
|
764
|
+
|
|
765
|
+
def perform
|
|
766
|
+
InvoiceService.issue(invoice)
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
def normalise_email
|
|
770
|
+
self.recipient_email = recipient_email&.strip&.downcase
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def log_issuance
|
|
774
|
+
Rails.logger.info("Invoice issued to #{recipient_email}")
|
|
775
|
+
end
|
|
776
|
+
end
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
`submit` works the same way — it runs validations, assigns attributes to the resource, calls `perform`, and returns the resource on success or `false` on failure.
|
|
780
|
+
|
|
781
|
+
If there's no meaningful resource to wrap at all, use `WellFormed::ActionForm` instead.
|
|
782
|
+
|
|
783
|
+
## Translations
|
|
784
|
+
|
|
785
|
+
WellFormed adds a generic base error when a save or perform fails with no errors already present. The default messages can be overridden in your application's locale files under the form's `resource_alias` key:
|
|
786
|
+
|
|
787
|
+
```yaml
|
|
788
|
+
en:
|
|
789
|
+
activemodel:
|
|
790
|
+
errors:
|
|
791
|
+
models:
|
|
792
|
+
article: # matches resource_alias :article
|
|
793
|
+
could_not_be_saved: "could not be saved"
|
|
794
|
+
publish_article: # matches resource_alias :publish_article
|
|
795
|
+
could_not_be_performed: "could not be performed"
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
To override globally for all forms, use the shared messages scope:
|
|
799
|
+
|
|
800
|
+
```yaml
|
|
801
|
+
en:
|
|
802
|
+
activemodel:
|
|
803
|
+
errors:
|
|
804
|
+
messages:
|
|
805
|
+
could_not_be_saved: "could not be saved"
|
|
806
|
+
could_not_be_performed: "could not be performed"
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
## Simple variants
|
|
810
|
+
|
|
811
|
+
Each base class has a **Simple** counterpart that drops the `user` argument from the constructor — useful in contexts where there is no current user, such as background jobs, microservices, or data-import pipelines.
|
|
812
|
+
|
|
813
|
+
| With user | Without user | Constructor |
|
|
814
|
+
|-----------|--------------|-------------|
|
|
815
|
+
| `WellFormed::ResourceForm` | `WellFormed::SimpleResource` | `(resource, params = {})` |
|
|
816
|
+
| `WellFormed::ActionForm` | `WellFormed::SimpleAction` | `(resource, params = {})` |
|
|
817
|
+
| `WellFormed::Struct` | `WellFormed::SimpleStruct` | `(resource, params = {})` |
|
|
818
|
+
|
|
819
|
+
Everything else — attributes, validations, callbacks, collections, nested attributes — works identically. Calling `form.user` on a Simple class raises `NoMethodError`.
|
|
820
|
+
|
|
821
|
+
```ruby
|
|
822
|
+
class SyncProductForm < WellFormed::SimpleResource
|
|
823
|
+
resource_alias :product
|
|
824
|
+
|
|
825
|
+
attribute :name, :string
|
|
826
|
+
attribute :price, :decimal
|
|
827
|
+
|
|
828
|
+
validates :name, presence: true
|
|
829
|
+
validates :price, numericality: { greater_than: 0 }
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
form = SyncProductForm.new(Product.new, { name: "Widget", price: 9.99 })
|
|
833
|
+
form.save # => true / false
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
### `WellFormed::WithUser`
|
|
837
|
+
|
|
838
|
+
If you need to add user support to a custom class that already inherits from one of the Simple variants, prepend `WellFormed::WithUser`. It overrides the constructor to accept `(resource, user, params = {})` and exposes `form.user`:
|
|
839
|
+
|
|
840
|
+
```ruby
|
|
841
|
+
class AuditedSyncForm < WellFormed::SimpleResource
|
|
842
|
+
prepend WellFormed::WithUser
|
|
843
|
+
|
|
844
|
+
before_save { AuditLog.record(user, resource) }
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
form = AuditedSyncForm.new(record, current_user, params)
|
|
848
|
+
form.user # => current_user
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
`ResourceForm`, `ActionForm`, and `Struct` are themselves implemented this way — they inherit from their Simple counterpart and prepend `WithUser`.
|
|
852
|
+
|
|
853
|
+
## Development
|
|
854
|
+
|
|
855
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
856
|
+
|
|
857
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
858
|
+
|
|
859
|
+
## Contributing
|
|
860
|
+
|
|
861
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/resource_form. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/resource_form/blob/main/CODE_OF_CONDUCT.md).
|
|
862
|
+
|
|
863
|
+
## License
|
|
864
|
+
|
|
865
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
866
|
+
|
|
867
|
+
## Code of Conduct
|
|
868
|
+
|
|
869
|
+
Everyone interacting in the ResourceForm project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/resource_form/blob/main/CODE_OF_CONDUCT.md).
|