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
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
-
#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.
- #persisted? ⇒ Boolean
-
#to_h ⇒ Object
Reads every attribute signal, so anything derived from it tracks them all.
Instance Attribute Details
#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.
99 100 101 |
# File 'lib/hibiki/rails/reactive_form.rb', line 99 def record @record end |
Class Method Details
.included(base) ⇒ Object
38 39 40 41 42 43 44 |
# File 'lib/hibiki/rails/reactive_form.rb', line 38 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
124 125 126 127 128 129 130 131 132 133 |
# File 'lib/hibiki/rails/reactive_form.rb', line 124 def commit record = __hibiki_record! if record.update(**to_h) hydrate(record) true else __hibiki_errors.value = record.errors.to_hash false end end |
#commit! ⇒ Object
The raising half. #commit has already assigned the attributes and mirrored the errors, so #save! re-validates the same record and raises ActiveRecord::RecordInvalid — no rescue, and no AR constant named here.
140 |
# File 'lib/hibiki/rails/reactive_form.rb', line 140 def commit! = commit || __hibiki_record!.save! |
#error_for(name) ⇒ Object
147 |
# File 'lib/hibiki/rails/reactive_form.rb', line 147 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.
145 |
# File 'lib/hibiki/rails/reactive_form.rb', line 145 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.
103 104 105 106 107 108 109 110 111 |
# File 'lib/hibiki/rails/reactive_form.rb', line 103 def hydrate(record) @record = record Hibiki.batch do self.class.hibiki_attributes.each { |name| public_send(:"#{name}=", record.public_send(name)) } __hibiki_snapshot.value = to_h __hibiki_errors.value = {} end self end |
#persisted? ⇒ Boolean
113 |
# File 'lib/hibiki/rails/reactive_form.rb', line 113 def persisted? = record&.persisted? || false |
#to_h ⇒ Object
Reads every attribute signal, so anything derived from it tracks them all.
117 |
# File 'lib/hibiki/rails/reactive_form.rb', line 117 def to_h = self.class.hibiki_attributes.to_h { |name| [name, public_send(name)] } |