Module: Hibiki::Rails::ReactiveForm
- Defined in:
- lib/hibiki/rails/reactive_form.rb
Overview
A reactive form object over one ActiveRecord record: hydrate its attributes into signals at one edge, work reactively in the middle, commit back at the other. The record itself never enters the graph.
class TodoForm
include Hibiki::Rails::ReactiveForm
reactive_attributes Todo, :title, :done
reactive_association :tags # defines the tag_ids signal
reactive_nested :steps, "StepForm" # an array-of-child-forms signal
derived(:title_error) { "can't be blank" if title.strip.empty? }
derived(:valid?) { title_error.nil? }
end
form = TodoForm.from(Todo.find(id)) # or Todo.new — see below
form.title = "buy milk" # a plain signal write
form.dirty? # => true
form.commit # => false if invalid
form.error_for(:title) # => the model's own message
One form class serves create AND update, the form_with model:
convention: from(Todo.new) hydrates the column defaults and commit
on an unpersisted record INSERTs, so create-vs-update is invisible to
the caller. dirty? on a create form means "changed from the
defaults" — exactly what enables a Create button.
Two layers of validation, deliberately: hand-written deriveds give
per-keystroke feedback (hand-picked, like client-side validation),
while the model's own validates stay authoritative at commit and
land in #errors. Nothing here names an ActiveRecord constant — the
record is duck-typed (readers, #update, #save!, #errors,
#persisted?) — but the casting below is AR's attribute API, which is
why this lives in the Rails glue gem and not in the core.
Defined Under Namespace
Modules: ClassMethods
Instance Attribute Summary collapse
-
#nested_key ⇒ Object
readonly
Stable identity for a child form across repaints: "c
" for persisted rows, "n " for added ones. -
#record ⇒ Object
readonly
The record, held in a plain ivar and NEVER in a signal: it is the boundary, touched only by #hydrate and #commit.
Class Method Summary collapse
Instance Method Summary collapse
-
#commit ⇒ Object
Write the record.
-
#commit! ⇒ Object
The raising half.
- #error_for(name) ⇒ Object
-
#errors ⇒ Object
{ title: ["can't be blank"] } — mirrored at a failed commit, cleared at a successful one.
-
#hydrate(record) ⇒ Object
Also the "reset from a reloaded record" path.
-
#mark_for_destruction ⇒ Object
AR's spelling; #hydrate is the unmark.
- #marked_for_destruction? ⇒ Boolean
-
#nested_add(name) ⇒ Object
Append a fresh child form (hydrated from the child model's column defaults) and return it.
-
#nested_move(name, child, to:) ⇒ Object
Move a child to index
toamong its VISIBLE siblings — the index the rendered rows show, so marked rows can't shift the target. -
#nested_remove(name, child) ⇒ Object
A persisted child is kept and marked (_destroy does the work at commit); a new one simply leaves the array.
- #persisted? ⇒ Boolean
-
#to_h ⇒ Object
Reads every attribute signal (child forms' included), so anything derived from it tracks them all.
-
#to_nested_attributes ⇒ Object
This form's slice of a parent's *_attributes: the id ties the hash to its row (new children have none), _destroy rides along for allow_destroy, and #to_h recursion carries any grandchildren.
Instance Attribute Details
#nested_key ⇒ Object
Stable identity for a child form across repaints: "c
209 210 211 |
# File 'lib/hibiki/rails/reactive_form.rb', line 209 def nested_key @nested_key end |
#record ⇒ Object (readonly)
The record, held in a plain ivar and NEVER in a signal: it is the boundary, touched only by #hydrate and #commit.
166 167 168 |
# File 'lib/hibiki/rails/reactive_form.rb', line 166 def record @record end |
Class Method Details
.included(base) ⇒ Object
40 41 42 43 44 45 46 |
# File 'lib/hibiki/rails/reactive_form.rb', line 40 def self.included(base) base.include(Hibiki::Reactive) base.extend(ClassMethods) # One derived over the whole attribute set rather than per-field # change tracking: cheap, and enough for "enable the save button". base.derived(:dirty?) { to_h != __hibiki_snapshot.value } end |
Instance Method Details
#commit ⇒ Object
Write the record. Returns false and mirrors the model's errors into #errors when validation fails (the Rails #save convention). On success the form re-hydrates: callbacks and database defaults may have moved values, and #persisted? flips after an INSERT. rubocop:disable Naming/PredicateMethod -- boolean without a ?, exactly like AR's #save
261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 |
# File 'lib/hibiki/rails/reactive_form.rb', line 261 def commit record = __hibiki_record! if record.update(**to_h) # Unload before re-hydrating, so the reload honors the # association's own scope and drops destroyed rows. __hibiki_reset_nested(record) hydrate(record) true else __hibiki_mirror_errors(record) # The form holds ONE record across attempts, and every failed # update APPENDS its built new children to the in-memory # association — a later success would insert them all again. # Unloading makes the next attempt start clean. __hibiki_reset_nested(record) false end end |
#commit! ⇒ Object
The raising half. Re-assigns and raises ActiveRecord::RecordInvalid (no rescue, and no AR constant named here); the nested associations are unloaded even on the raise, so a rescued retry starts clean.
284 285 286 287 288 289 290 291 292 293 |
# File 'lib/hibiki/rails/reactive_form.rb', line 284 def commit! return true if commit record = __hibiki_record! begin record.update!(**to_h) ensure __hibiki_reset_nested(record) end end |
#error_for(name) ⇒ Object
300 |
# File 'lib/hibiki/rails/reactive_form.rb', line 300 def error_for(name) = errors[name.to_sym]&.first |
#errors ⇒ Object
{ title: ["can't be blank"] } — mirrored at a failed commit, cleared at a successful one. Reactive like any other signal read: an effect over #error_for repaints when a commit fails.
298 |
# File 'lib/hibiki/rails/reactive_form.rb', line 298 def errors = __hibiki_errors.value |
#hydrate(record) ⇒ Object
Also the "reset from a reloaded record" path. One batch, so a re-hydrate is one effect run rather than one per attribute. Children are rebuilt before the snapshot, so dirty? sees the whole tree as clean.
172 173 174 175 176 177 178 179 180 181 182 |
# File 'lib/hibiki/rails/reactive_form.rb', line 172 def hydrate(record) @record = record Hibiki.batch do __hibiki_hydrate_nested(record) self.class.hibiki_attributes.each { |name| public_send(:"#{name}=", record.public_send(name)) } __hibiki_destroy.value = false __hibiki_snapshot.value = to_h __hibiki_errors.value = {} end self end |
#mark_for_destruction ⇒ Object
AR's spelling; #hydrate is the unmark.
250 251 252 |
# File 'lib/hibiki/rails/reactive_form.rb', line 250 def mark_for_destruction __hibiki_destroy.value = true end |
#marked_for_destruction? ⇒ Boolean
254 |
# File 'lib/hibiki/rails/reactive_form.rb', line 254 def marked_for_destruction? = __hibiki_destroy.value |
#nested_add(name) ⇒ Object
Append a fresh child form (hydrated from the child model's column defaults) and return it. The array is replaced, never mutated — signals notify on assignment.
214 215 216 217 218 219 220 |
# File 'lib/hibiki/rails/reactive_form.rb', line 214 def nested_add(name) form_class = self.class.hibiki_nested_form(name) child = form_class.from(form_class.hibiki_model.new) child.nested_key = "n#{@__hibiki_key_seq = (@__hibiki_key_seq || 0) + 1}" public_send(:"#{name}=", [*public_send(name), child]) child end |
#nested_move(name, child, to:) ⇒ Object
Move a child to index to among its VISIBLE siblings — the index
the rendered rows show, so marked rows can't shift the target.
They ride along at the tail; their order never matters (position
stamping and rendering both skip them). Clamped, so any integer is
safe.
238 239 240 241 242 243 244 245 246 247 |
# File 'lib/hibiki/rails/reactive_form.rb', line 238 def nested_move(name, child, to:) children = public_send(name) return child if child.marked_for_destruction? || !children.include?(child) visible = children.reject(&:marked_for_destruction?) visible.delete(child) visible.insert(to.clamp(0, visible.size), child) public_send(:"#{name}=", visible + (children - visible)) child end |
#nested_remove(name, child) ⇒ Object
A persisted child is kept and marked (_destroy does the work at commit); a new one simply leaves the array.
224 225 226 227 228 229 230 231 |
# File 'lib/hibiki/rails/reactive_form.rb', line 224 def nested_remove(name, child) if child.persisted? child.mark_for_destruction else public_send(:"#{name}=", public_send(name) - [child]) end child end |
#persisted? ⇒ Boolean
184 |
# File 'lib/hibiki/rails/reactive_form.rb', line 184 def persisted? = record&.persisted? || false |
#to_h ⇒ Object
Reads every attribute signal (child forms' included), so anything derived from it tracks them all.
188 189 190 191 192 193 194 |
# File 'lib/hibiki/rails/reactive_form.rb', line 188 def to_h h = self.class.hibiki_attributes.to_h { |name| [name, public_send(name)] } self.class.hibiki_nested.each_key do |name| h[:"#{name}_attributes"] = public_send(name).map(&:to_nested_attributes) end h end |
#to_nested_attributes ⇒ Object
This form's slice of a parent's *_attributes: the id ties the hash to its row (new children have none), _destroy rides along for allow_destroy, and #to_h recursion carries any grandchildren.
199 200 201 202 203 204 |
# File 'lib/hibiki/rails/reactive_form.rb', line 199 def to_nested_attributes attrs = to_h attrs[:id] = record.id if persisted? attrs[:_destroy] = marked_for_destruction? attrs end |