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

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#nested_keyObject

Stable identity for a child form across repaints: "c" for persisted rows, "n" for added ones. Assigned by the parent — a standalone form has none.



209
210
211
# File 'lib/hibiki/rails/reactive_form.rb', line 209

def nested_key
  @nested_key
end

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

#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



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

#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.



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_destructionObject

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

Returns:

  • (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

Returns:

  • (Boolean)


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

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

#to_hObject

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_attributesObject

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