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

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#recordObject (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

#commitObject

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

#errorsObject

{ 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

Returns:

  • (Boolean)


113
# File 'lib/hibiki/rails/reactive_form.rb', line 113

def persisted? = record&.persisted? || false

#to_hObject

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)] }