dexkit 0.2.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3adbbfa21a62c17b74b7ac6807ed7a9abdca2b02c85e06bb2d542bdbf7d39677
4
- data.tar.gz: cead77e688d4c30a7256c05f73d509fefd44c7c6b6bfc52d9b0bdbfd8675954e
3
+ metadata.gz: bc994e44218cf9063af69ad1d01929d86b91fe35b6f30769dd068832fb04fc37
4
+ data.tar.gz: b4a078fb25780824cd2a876f5d668196ba2f3a2094ce63a1b9731dfeab1c7aa6
5
5
  SHA512:
6
- metadata.gz: b58aec14261efd1c679bb662b163df64f96d86239e5efe8897d7b8d007aef4cd19864a8fabc03ee4acf139379c1185e56f8a5f6e488a1f5dec00442a24223dee
7
- data.tar.gz: 6009bd9567cf1fd4adacf21cfb5c6627cfce8d1d399498923ae9ff8a11c3f2b31df884549a8b3bdb0c45bede9951b672c6d50dbf2199a691e45a79c37b5c1497
6
+ metadata.gz: b4bb8dbbe3c9acd66e5c3ed6a6b203408f66c7455544c36de4e60e755a9cfa9a2fa3405a39648e77e5bd42b2593a053d5d517e16ce1cd5236225083bd24891b3
7
+ data.tar.gz: c050bd2dc477e19efcebbe6bfa6fe7d46dbfa4dea5367f38c098b6c82750439211d1b4a28224c21fd66e98c8971b9532f341f4e17b4d2e836383470ccf778913
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-03-03
4
+
5
+ ### Added
6
+
7
+ - **Form objects** — `Dex::Form` base class for user-facing input handling
8
+ - Typed attributes via ActiveModel (`attribute :name, :string`)
9
+ - Normalization on assignment (`normalizes :email, with: -> { _1&.strip&.downcase }`)
10
+ - Full ActiveModel validation support, including custom `uniqueness` validator with scope, case-insensitive matching, conditions, and record exclusion
11
+ - `model` DSL for binding a form to an ActiveRecord model class (drives `model_name`, `persisted?`, `to_key`, `to_param`)
12
+ - `record` reader and `with_record` chainable method for edit/update forms — record is excluded from form attributes and protected from mass assignment
13
+ - `nested_one` / `nested_many` DSL for nested form objects with auto-generated constants, `build_` methods, and `_attributes=` setters
14
+ - Hash coercion, Rails numbered hash format, and `_destroy` filtering in nested forms
15
+ - Validation propagation from nested forms with prefixed error keys (`address.street`, `documents[0].doc_type`)
16
+ - `ActionController::Parameters` support — strong parameters (`permit`) not required; the form's attribute declarations are the whitelist
17
+ - `to_h` / `to_hash` serialization including nested forms
18
+ - `ValidationError` for bang-style save patterns
19
+ - Full Rails `form_with` / `fields_for` compatibility
20
+ - **Form uniqueness validator** — `validates :email, uniqueness: true`
21
+ - Model resolution chain: explicit `model:` option, `model` DSL, or infer from class name (`UserForm` → `User`)
22
+ - Options: `scope`, `case_sensitive`, `conditions` (zero-arg or form-arg lambda), `attribute` mapping, `message`
23
+ - Excludes current record via model's `primary_key` (not hardcoded `id`)
24
+ - Declaration-time validation of `model:` and `conditions:` options
25
+ - Added `actionpack` as development dependency for testing Rails controller integration
26
+ - Added `activemodel >= 6.1` as runtime dependency
27
+
28
+ ### Changed
29
+
30
+ - `Dex::Match` is now included in `Dex::Operation` – `Ok`/`Err` are available without prefix inside operations. External contexts (controllers, POROs) can still use `Dex::Ok`/`Dex::Err` or `include Dex::Match`.
31
+
3
32
  ## [0.2.0] - 2026-03-02
4
33
 
5
34
  ### Added
data/README.md CHANGED
@@ -48,8 +48,6 @@ rescue_from Stripe::CardError, as: :card_declined
48
48
  **Ok / Err** – pattern match on operation outcomes with `.safe.call`:
49
49
 
50
50
  ```ruby
51
- include Dex::Match
52
-
53
51
  case CreateUser.new(email: email).safe.call
54
52
  in Ok(name:)
55
53
  puts "Welcome, #{name}!"
@@ -138,6 +136,109 @@ class CreateOrderTest < Minitest::Test
138
136
  end
139
137
  ```
140
138
 
139
+ ## Forms
140
+
141
+ Form objects with typed attributes, normalization, nested forms, and Rails form builder compatibility.
142
+
143
+ ```ruby
144
+ class OnboardingForm < Dex::Form
145
+ model User
146
+
147
+ attribute :first_name, :string
148
+ attribute :last_name, :string
149
+ attribute :email, :string
150
+
151
+ normalizes :email, with: -> { _1&.strip&.downcase.presence }
152
+
153
+ validates :email, presence: true, uniqueness: true
154
+ validates :first_name, :last_name, presence: true
155
+
156
+ nested_one :address do
157
+ attribute :street, :string
158
+ attribute :city, :string
159
+ validates :street, :city, presence: true
160
+ end
161
+ end
162
+
163
+ form = OnboardingForm.new(email: " ALICE@EXAMPLE.COM ", first_name: "Alice", last_name: "Smith")
164
+ form.email # => "alice@example.com"
165
+ form.valid?
166
+ ```
167
+
168
+ ### What you get out of the box
169
+
170
+ **ActiveModel attributes** with type casting, normalization, and full Rails validation DSL.
171
+
172
+ **Nested forms** — `nested_one` and `nested_many` with automatic Hash coercion, `_destroy` support, and error propagation:
173
+
174
+ ```ruby
175
+ nested_many :documents do
176
+ attribute :document_type, :string
177
+ attribute :document_number, :string
178
+ validates :document_type, :document_number, presence: true
179
+ end
180
+ ```
181
+
182
+ **Rails form compatibility** — works with `form_with`, `fields_for`, and nested attributes out of the box.
183
+
184
+ **Uniqueness validation** against the database, with scope, case-sensitivity, and current-record exclusion.
185
+
186
+ **Multi-model forms** — when a form spans User, Employee, and Address, define a `.for` convention method to map records and a `#save` method that delegates to a `Dex::Operation`:
187
+
188
+ ```ruby
189
+ def save
190
+ return false unless valid?
191
+
192
+ case operation.safe.call
193
+ in Ok then true
194
+ in Err => e then errors.add(:base, e.message) and false
195
+ end
196
+ end
197
+ ```
198
+
199
+ ## Queries
200
+
201
+ Declarative query objects for filtering and sorting ActiveRecord relations.
202
+
203
+ ```ruby
204
+ class UserSearch < Dex::Query
205
+ scope { User.all }
206
+
207
+ prop? :name, String
208
+ prop? :role, _Array(String)
209
+ prop? :age_min, Integer
210
+
211
+ filter :name, :contains
212
+ filter :role, :in
213
+ filter :age_min, :gte, column: :age
214
+
215
+ sort :name, :created_at, default: "-created_at"
216
+ end
217
+
218
+ users = UserSearch.call(name: "ali", role: %w[admin], sort: "name")
219
+ ```
220
+
221
+ ### What you get out of the box
222
+
223
+ **11 built-in filter strategies** — `:eq`, `:not_eq`, `:contains`, `:starts_with`, `:ends_with`, `:gt`, `:gte`, `:lt`, `:lte`, `:in`, `:not_in`. Custom blocks for complex logic.
224
+
225
+ **Sorting** with ascending/descending column sorts, custom sort blocks, and defaults.
226
+
227
+ **`from_params`** — HTTP boundary handling with automatic coercion, blank stripping, and invalid value fallback:
228
+
229
+ ```ruby
230
+ class UsersController < ApplicationController
231
+ def index
232
+ query = UserSearch.from_params(params, scope: policy_scope(User))
233
+ @users = pagy(query.resolve)
234
+ end
235
+ end
236
+ ```
237
+
238
+ **Form binding** — works with `form_with` for search forms. Queries respond to `model_name`, `param_key`, `persisted?`, and `to_params`.
239
+
240
+ **Scope injection** — narrow the base scope at call time without modifying the query class.
241
+
141
242
  ## Installation
142
243
 
143
244
  ```ruby
@@ -155,6 +256,8 @@ Dexkit ships LLM-optimized guides. Copy them into your project so AI agents auto
155
256
  ```bash
156
257
  cp $(bundle show dexkit)/guides/llm/OPERATION.md app/operations/CLAUDE.md
157
258
  cp $(bundle show dexkit)/guides/llm/EVENT.md app/event_handlers/CLAUDE.md
259
+ cp $(bundle show dexkit)/guides/llm/FORM.md app/forms/CLAUDE.md
260
+ cp $(bundle show dexkit)/guides/llm/QUERY.md app/queries/CLAUDE.md
158
261
  ```
159
262
 
160
263
  ## License
data/Untitled ADDED
@@ -0,0 +1,12 @@
1
+ P2
2
+ Preserve explicit nils in `from_params` initialization
3
+ from_params explicitly assigns nil for blank optional values, but new(**kwargs.compact) removes those keys before initialization. This breaks the documented "blank/invalid to nil" behavior whenever an optional prop has a non-nil default (prop? ... default: ...), because blank or uncoercible input silently falls back to the default and applies unintended filters.
4
+
5
+
6
+
7
+ /Users/razorjack/Projects/OpenSource/dexkit/lib/dex/query.rb:134-134
8
+ P3
9
+ Validate `param_key` input before caching model name
10
+ param_key accepts any truthy value and stores key.to_s without validation, so param_key "" is accepted but later crashes in model_name when ActiveModel::Name.new receives a blank class name. This turns a declaration-time DSL error into a runtime failure in form binding/from_params, which is harder to diagnose.
11
+ /Users/razorjack/Projects/OpenSource/dexkit/lib/dex/query.rb:58-60
12
+
@@ -0,0 +1,520 @@
1
+ # Dex::Form — LLM Reference
2
+
3
+ Copy this to your app's forms directory (e.g., `app/forms/AGENTS.md`) so coding agents know the full API when implementing and testing forms.
4
+
5
+ ---
6
+
7
+ ## Reference Form
8
+
9
+ All examples below build on this form unless noted otherwise:
10
+
11
+ ```ruby
12
+ class OnboardingForm < Dex::Form
13
+ model User
14
+
15
+ attribute :first_name, :string
16
+ attribute :last_name, :string
17
+ attribute :email, :string
18
+ attribute :department, :string
19
+ attribute :start_date, :date
20
+
21
+ normalizes :email, with: -> { _1&.strip&.downcase.presence }
22
+
23
+ validates :email, presence: true, uniqueness: true
24
+ validates :first_name, :last_name, :department, presence: true
25
+ validates :start_date, presence: true
26
+
27
+ nested_one :address do
28
+ attribute :street, :string
29
+ attribute :city, :string
30
+ attribute :postal_code, :string
31
+ attribute :country, :string
32
+
33
+ validates :street, :city, :country, presence: true
34
+ end
35
+
36
+ nested_many :documents do
37
+ attribute :document_type, :string
38
+ attribute :document_number, :string
39
+
40
+ validates :document_type, :document_number, presence: true
41
+ end
42
+ end
43
+ ```
44
+
45
+ ---
46
+
47
+ ## Defining Forms
48
+
49
+ Forms use ActiveModel under the hood. Attributes are declared with `attribute` (same as ActiveModel::Attributes).
50
+
51
+ ```ruby
52
+ class ProfileForm < Dex::Form
53
+ attribute :name, :string
54
+ attribute :age, :integer
55
+ attribute :bio, :string
56
+ attribute :active, :boolean, default: true
57
+ attribute :born_on, :date
58
+ end
59
+ ```
60
+
61
+ ### Available types
62
+
63
+ `:string`, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`, `:time`.
64
+
65
+ ### `model(klass)`
66
+
67
+ Declares the backing model class. Used by:
68
+ - `model_name` — delegates to model for Rails routing (`form_with model: @form`)
69
+ - `validates :attr, uniqueness: true` — queries this model
70
+ - `persisted?` — delegates to `record`
71
+
72
+ ```ruby
73
+ class UserForm < Dex::Form
74
+ model User
75
+ end
76
+ ```
77
+
78
+ Optional. Multi-model forms often skip it.
79
+
80
+ ---
81
+
82
+ ## Normalization
83
+
84
+ Uses Rails' `normalizes` (Rails 7.1+). Applied on every assignment.
85
+
86
+ ```ruby
87
+ normalizes :email, with: -> { _1&.strip&.downcase.presence }
88
+ normalizes :phone, with: -> { _1&.gsub(/\D/, "").presence }
89
+ normalizes :name, :email, with: -> { _1&.strip.presence } # multiple attrs
90
+ ```
91
+
92
+ ---
93
+
94
+ ## Validation
95
+
96
+ Full ActiveModel validation DSL:
97
+
98
+ ```ruby
99
+ validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
100
+ validates :name, presence: true, length: { minimum: 2, maximum: 100 }
101
+ validates :role, inclusion: { in: %w[admin user] }
102
+ validates :email, uniqueness: true # checks database
103
+ validate :custom_validation_method
104
+ ```
105
+
106
+ ```ruby
107
+ form.valid? # => true/false
108
+ form.invalid? # => true/false
109
+ form.errors # => ActiveModel::Errors
110
+ form.errors[:email] # => ["can't be blank"]
111
+ form.errors.full_messages # => ["Email can't be blank"]
112
+ ```
113
+
114
+ ### ValidationError
115
+
116
+ ```ruby
117
+ error = Dex::Form::ValidationError.new(form)
118
+ error.message # => "Validation failed: Email can't be blank, Name can't be blank"
119
+ error.form # => the form instance
120
+ ```
121
+
122
+ ---
123
+
124
+ ## Nested Forms
125
+
126
+ ### `nested_one`
127
+
128
+ One-to-one nested form. Automatically coerces Hash input.
129
+
130
+ ```ruby
131
+ nested_one :address do
132
+ attribute :street, :string
133
+ attribute :city, :string
134
+ validates :street, :city, presence: true
135
+ end
136
+ ```
137
+
138
+ ```ruby
139
+ form = MyForm.new(address: { street: "123 Main", city: "NYC" })
140
+ form.address.street # => "123 Main"
141
+ form.address.class # => MyForm::Address (auto-generated)
142
+
143
+ form.build_address(street: "456 Oak") # build new nested
144
+ form.address_attributes = { city: "Boston" } # Rails compat setter
145
+ ```
146
+
147
+ Default: initialized as empty form when not provided.
148
+
149
+ ### `nested_many`
150
+
151
+ One-to-many nested form. Handles Array, Rails numbered Hash, and `_destroy`.
152
+
153
+ ```ruby
154
+ nested_many :documents do
155
+ attribute :document_type, :string
156
+ attribute :document_number, :string
157
+ validates :document_type, :document_number, presence: true
158
+ end
159
+ ```
160
+
161
+ ```ruby
162
+ # Array of hashes
163
+ form = MyForm.new(documents: [
164
+ { document_type: "passport", document_number: "AB123" },
165
+ { document_type: "visa", document_number: "CD456" }
166
+ ])
167
+
168
+ # Rails numbered hash format (from form submissions)
169
+ form = MyForm.new(documents: {
170
+ "0" => { document_type: "passport", document_number: "AB123" },
171
+ "1" => { document_type: "visa", document_number: "CD456" }
172
+ })
173
+
174
+ # _destroy support
175
+ form = MyForm.new(documents: [
176
+ { document_type: "passport", document_number: "AB123" },
177
+ { document_type: "visa", document_number: "CD456", _destroy: "1" } # filtered out
178
+ ])
179
+ form.documents.size # => 1
180
+
181
+ form.build_document(document_type: "id_card") # append new
182
+ form.documents_attributes = { "0" => { document_type: "id_card" } } # Rails compat setter
183
+ ```
184
+
185
+ Default: initialized as `[]` when not provided.
186
+
187
+ ### `class_name` option
188
+
189
+ Override the auto-generated constant name:
190
+
191
+ ```ruby
192
+ nested_one :address, class_name: "HomeAddress" do
193
+ attribute :street, :string
194
+ end
195
+ # Creates MyForm::HomeAddress instead of MyForm::Address
196
+ ```
197
+
198
+ ### Validation propagation
199
+
200
+ Nested errors bubble up with prefixed attribute names:
201
+
202
+ ```ruby
203
+ form.valid?
204
+ form.errors[:"address.street"] # => ["can't be blank"]
205
+ form.errors[:"documents[0].doc_type"] # => ["can't be blank"]
206
+ form.errors.full_messages
207
+ # => ["Address street can't be blank", "Documents[0] doc type can't be blank"]
208
+ ```
209
+
210
+ ---
211
+
212
+ ## Uniqueness Validation
213
+
214
+ Checks uniqueness against the database.
215
+
216
+ ```ruby
217
+ validates :email, uniqueness: true
218
+ ```
219
+
220
+ ### Options
221
+
222
+ | Option | Description | Example |
223
+ |--------|-------------|---------|
224
+ | `model:` | Explicit model class | `uniqueness: { model: User }` |
225
+ | `attribute:` | Column name if different | `uniqueness: { attribute: :email }` |
226
+ | `scope:` | Scoped uniqueness | `uniqueness: { scope: :tenant_id }` |
227
+ | `case_sensitive:` | Case-insensitive check | `uniqueness: { case_sensitive: false }` |
228
+ | `conditions:` | Extra query conditions | `uniqueness: { conditions: -> { where(active: true) } }` |
229
+ | `message:` | Custom error message | `uniqueness: { message: "already registered" }` |
230
+
231
+ ### Model resolution
232
+
233
+ 1. `options[:model]` (explicit)
234
+ 2. `form.class._model_class` (from `model` DSL)
235
+ 3. Infer from class name: `RegistrationForm` → `Registration`
236
+ 4. If none found, validation is a no-op
237
+
238
+ ### Record exclusion
239
+
240
+ When `form.record` is persisted, the current record is excluded from the uniqueness check (for updates).
241
+
242
+ ---
243
+
244
+ ## Record Binding
245
+
246
+ Use `with_record` to associate a model instance with the form. This is the recommended approach in controllers – it keeps the record separate from user-submitted params.
247
+
248
+ ```ruby
249
+ # Chainable — preferred in controllers
250
+ form = OnboardingForm.new(params.require(:onboarding)).with_record(user)
251
+
252
+ # Constructor hash — convenient in plain Ruby / tests
253
+ form = OnboardingForm.new(name: "Alice", record: user)
254
+ ```
255
+
256
+ ```ruby
257
+ form.record # => the ActiveRecord instance (or nil)
258
+ form.persisted? # => true if record is present and persisted
259
+ form.to_key # => delegates to record (for URL generation)
260
+ form.to_param # => delegates to record
261
+ ```
262
+
263
+ `record` is read-only after construction — there is no public `record=` setter.
264
+
265
+ ---
266
+
267
+ ## Serialization
268
+
269
+ ```ruby
270
+ form.to_h
271
+ # => {
272
+ # first_name: "Alice", last_name: "Smith", email: "alice@example.com",
273
+ # address: { street: "123 Main", city: "NYC", postal_code: nil, country: nil },
274
+ # documents: [{ document_type: "passport", document_number: "AB123" }]
275
+ # }
276
+
277
+ form.to_hash # alias for to_h
278
+ ```
279
+
280
+ ---
281
+
282
+ ## Rails Integration
283
+
284
+ ### `form_with`
285
+
286
+ ```erb
287
+ <%= form_with model: @form, url: onboarding_path do |f| %>
288
+ <%= f.text_field :first_name %>
289
+ <%= f.text_field :last_name %>
290
+ <%= f.email_field :email %>
291
+
292
+ <%= f.fields_for :address do |a| %>
293
+ <%= a.text_field :street %>
294
+ <%= a.text_field :city %>
295
+ <% end %>
296
+
297
+ <%= f.fields_for :documents do |d| %>
298
+ <%= d.text_field :document_type %>
299
+ <%= d.text_field :document_number %>
300
+ <%= d.hidden_field :_destroy %>
301
+ <% end %>
302
+
303
+ <%= f.submit %>
304
+ <% end %>
305
+ ```
306
+
307
+ ### Controller pattern
308
+
309
+ Strong parameters (`permit`) are not required — the form's attribute declarations are the whitelist. Just `require` the top-level key:
310
+
311
+ ```ruby
312
+ class OnboardingController < ApplicationController
313
+ def new
314
+ @form = OnboardingForm.new
315
+ end
316
+
317
+ def create
318
+ @form = OnboardingForm.new(params.require(:onboarding))
319
+
320
+ if @form.save
321
+ redirect_to dashboard_path
322
+ else
323
+ render :new, status: :unprocessable_entity
324
+ end
325
+ end
326
+
327
+ def edit
328
+ @form = OnboardingForm.for(current_user)
329
+ end
330
+
331
+ def update
332
+ @form = OnboardingForm.new(params.require(:onboarding)).with_record(current_user)
333
+
334
+ if @form.save
335
+ redirect_to dashboard_path
336
+ else
337
+ render :edit, status: :unprocessable_entity
338
+ end
339
+ end
340
+ end
341
+ ```
342
+
343
+ ---
344
+
345
+ ## Suggested Conventions
346
+
347
+ Dex::Form handles data holding, normalization, and validation. Persistence and record mapping are user-defined. These conventions are recommended:
348
+
349
+ ### `.for(record)` — load from record
350
+
351
+ ```ruby
352
+ class OnboardingForm < Dex::Form
353
+ def self.for(user)
354
+ employee = user.employee
355
+
356
+ new(
357
+ first_name: user.first_name,
358
+ last_name: user.last_name,
359
+ email: user.email,
360
+ department: employee.department,
361
+ start_date: employee.start_date,
362
+ address: {
363
+ street: employee.address.street, city: employee.address.city,
364
+ postal_code: employee.address.postal_code, country: employee.address.country
365
+ },
366
+ documents: employee.documents.map { |d|
367
+ { document_type: d.document_type, document_number: d.document_number }
368
+ }
369
+ ).with_record(user)
370
+ end
371
+ end
372
+ ```
373
+
374
+ ### `#save` — persist via Operation
375
+
376
+ ```ruby
377
+ class OnboardingForm < Dex::Form
378
+ def save
379
+ return false unless valid?
380
+
381
+ case operation.safe.call
382
+ in Ok then true
383
+ in Err => e then errors.add(:base, e.message) and false
384
+ end
385
+ end
386
+
387
+ private
388
+
389
+ def operation
390
+ Onboarding::Upsert.new(
391
+ user: record, first_name:, last_name:, email:,
392
+ department:, start_date:,
393
+ address: address.to_h,
394
+ documents: documents.map(&:to_h)
395
+ )
396
+ end
397
+ end
398
+ ```
399
+
400
+ ---
401
+
402
+ ## Complete Example
403
+
404
+ A form spanning User, Employee, and Address — the core reason form objects exist.
405
+
406
+ ```ruby
407
+ class OnboardingForm < Dex::Form
408
+ attribute :first_name, :string
409
+ attribute :last_name, :string
410
+ attribute :email, :string
411
+ attribute :department, :string
412
+ attribute :position, :string
413
+ attribute :start_date, :date
414
+
415
+ normalizes :email, with: -> { _1&.strip&.downcase.presence }
416
+
417
+ validates :email, presence: true, uniqueness: { model: User }
418
+ validates :first_name, :last_name, :department, :position, presence: true
419
+ validates :start_date, presence: true
420
+
421
+ nested_one :address do
422
+ attribute :street, :string
423
+ attribute :city, :string
424
+ attribute :postal_code, :string
425
+ attribute :country, :string
426
+
427
+ validates :street, :city, :country, presence: true
428
+ end
429
+
430
+ nested_many :documents do
431
+ attribute :document_type, :string
432
+ attribute :document_number, :string
433
+
434
+ validates :document_type, :document_number, presence: true
435
+ end
436
+
437
+ def self.for(user)
438
+ employee = user.employee
439
+
440
+ new(
441
+ first_name: user.first_name,
442
+ last_name: user.last_name,
443
+ email: user.email,
444
+ department: employee.department,
445
+ position: employee.position,
446
+ start_date: employee.start_date,
447
+ address: {
448
+ street: employee.address.street, city: employee.address.city,
449
+ postal_code: employee.address.postal_code, country: employee.address.country
450
+ },
451
+ documents: employee.documents.map { |d|
452
+ { document_type: d.document_type, document_number: d.document_number }
453
+ }
454
+ ).with_record(user)
455
+ end
456
+
457
+ def save
458
+ return false unless valid?
459
+
460
+ case operation.safe.call
461
+ in Ok then true
462
+ in Err => e then errors.add(:base, e.message) and false
463
+ end
464
+ end
465
+
466
+ private
467
+
468
+ def operation
469
+ Onboarding::Upsert.new(
470
+ user: record, first_name:, last_name:, email:,
471
+ department:, position:, start_date:,
472
+ address: address.to_h,
473
+ documents: documents.map(&:to_h)
474
+ )
475
+ end
476
+ end
477
+ ```
478
+
479
+ ---
480
+
481
+ ## Testing
482
+
483
+ Forms are standard ActiveModel objects. Test them with plain Minitest — no special helpers needed.
484
+
485
+ ```ruby
486
+ class OnboardingFormTest < Minitest::Test
487
+ def test_validates_required_fields
488
+ form = OnboardingForm.new
489
+ assert form.invalid?
490
+ assert form.errors[:email].any?
491
+ assert form.errors[:first_name].any?
492
+ end
493
+
494
+ def test_normalizes_email
495
+ form = OnboardingForm.new(email: " ALICE@EXAMPLE.COM ")
496
+ assert_equal "alice@example.com", form.email
497
+ end
498
+
499
+ def test_nested_validation_propagation
500
+ form = OnboardingForm.new(
501
+ first_name: "Alice", last_name: "Smith",
502
+ email: "alice@example.com", department: "Eng",
503
+ position: "Developer", start_date: Date.today,
504
+ address: { street: "", city: "", country: "" }
505
+ )
506
+ assert form.invalid?
507
+ assert form.errors[:"address.street"].any?
508
+ end
509
+
510
+ def test_to_h_serialization
511
+ form = OnboardingForm.new(
512
+ first_name: "Alice", email: "alice@example.com",
513
+ address: { street: "123 Main", city: "NYC" }
514
+ )
515
+ h = form.to_h
516
+ assert_equal "Alice", h[:first_name]
517
+ assert_equal "123 Main", h[:address][:street]
518
+ end
519
+ end
520
+ ```