Module: Forms::Live

Extended by:
ActiveSupport::Concern
Includes:
Phlex::Reactive::Component
Defined in:
lib/forms/live.rb,
lib/forms/live/field.rb

Overview

Server-truth live validation via phlex-reactive: the whole form is one reactive component. Each input's blur (and a debounced form-wide input trigger) POSTs every field to a single :validate action, which assigns a whitelisted slice to the model, runs the REAL ActiveModel validators — uniqueness, :if/:unless, confirmation, :on context all work — and replies with a focus-preserving morph. Nothing is ever persisted.

Enabled through the Forms::Base macro (inline forms cannot be live — the endpoint rebuilds the component from its class, and a caller-supplied block cannot be serialized):

class UserForm < Forms::Base
live model: User, debounce: 300
def fields
  field :email
  field :password
  field :password_confirmation
end
end

Identity is STATE-backed (not reactive_record): the token signs the model's GlobalID when persisted (nil for new records — reactive_record cannot round-trip an unsaved draft) plus the touched field list, so error display state survives every round trip tamper-proof, with zero client-side bookkeeping.

Defined Under Namespace

Classes: Field

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.register_param_type!Object

Registers the raw-hash param type the :validate schema is declared with. Called from the engine initializer (the registry freezes after boot); test suites call it from their spec helper.



36
37
38
39
40
41
42
# File 'lib/forms/live.rb', line 36

def self.register_param_type!
  return if Phlex::Reactive.param_type?(:form_attributes)

  Phlex::Reactive.param_type(:form_attributes) do |value|
    value.is_a?(Hash) ? value : Phlex::Reactive::ParamSchema::DROP
  end
end

Instance Method Details

#assign_live_attributes(attrs) ⇒ Object

Whitelisted assignment through public writers. Values are validated and rendered, never saved; use live_permit/live_deny to adjust the surface.



166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/forms/live.rb', line 166

def assign_live_attributes(attrs)
  return if attrs.blank?

  permitted = self.class.live_permitted_attributes(@model)
  attrs.each do |key, value|
    key = key.to_s
    next unless permitted.include?(key)

    setter = :"#{key}="
    @model.public_send(setter, value) if @model.respond_to?(setter)
  end
end

#field_object(name, error_name: nil) ⇒ Object

Untouched fields get no error set, so nothing flashes before the user leaves a field; each input carries its blur _touch trigger.



141
142
143
144
145
146
147
148
# File 'lib/forms/live.rb', line 141

def field_object(name, error_name: nil)
  show_errors = touched?(name) || touched?(error_name)
  Forms::Live::Field.new(
    name:, model: @model, scope: @scope, form: self, error_name:,
    errors: (show_errors ? @errors : nil),
    live_trigger: on(:validate, event: "blur", _touch: name.to_s)
  )
end

#form_attributesObject

The element is the reactive root: id + controller + signed token, plus the debounced whole-form input trigger (input events bubble; blur triggers live per-input via Live::Field because Stimulus params are per-element).



130
131
132
133
134
135
136
137
# File 'lib/forms/live.rb', line 130

def form_attributes
  attrs = super
  mix(
    attrs,
    reactive_root(id: attrs[:id] || id),
    on(:validate, event: "input", debounce: self.class.live_debounce)
  )
end

#idObject

Streamable's #id contract: the reactive root's stable DOM id.



111
112
113
# File 'lib/forms/live.rb', line 111

def id
  @options[:id] || "#{@model.respond_to?(:persisted?) && @model.persisted? ? 'edit' : 'new'}_#{@scope}"
end

#initialize(*modifiers, model: nil, model_gid: nil, touched: nil) ⇒ Object

Two construction modes: the app renders new(model:); the endpoint rebuilds new(model_gid:, touched:) from the signed state (see Component::DSL.from_identity — only declared state keys round-trip).



100
101
102
103
104
105
106
107
108
# File 'lib/forms/live.rb', line 100

def initialize(*modifiers, model: nil, model_gid: nil, touched: nil, **)
  model ||= locate_live_model(model_gid)
  @touched = Array(touched).map(&:to_s)
  super(*modifiers, model:, **)
  @model_gid = (@model.to_gid.to_s if signed_model_gid?)
  # A form re-rendered after a failed submit (classic 422) arrives with
  # errors already on the model — surface them without requiring touches.
  @touched |= @errors.attribute_names.map(&:to_s) if @errors.respond_to?(:attribute_names)
end

#locate_live_model(model_gid) ⇒ Object



154
155
156
157
158
# File 'lib/forms/live.rb', line 154

def locate_live_model(model_gid)
  return GlobalID::Locator.locate(model_gid) if model_gid.present?

  self.class.live_model_class.new
end

#signed_model_gid?Boolean

Returns:

  • (Boolean)


160
161
162
# File 'lib/forms/live.rb', line 160

def signed_model_gid?
  @model.respond_to?(:to_gid) && @model.respond_to?(:persisted?) && @model.persisted?
end

#touched?(name) ⇒ Boolean

Returns:

  • (Boolean)


150
151
152
# File 'lib/forms/live.rb', line 150

def touched?(name)
  name && @touched.include?(name.to_s)
end

#validate(_touch: nil, **posted) ⇒ Object

Assign → validate → morph. The reply preserves the focused input and its caret (Idiomorph); the fresh token re-signs the updated touched set. _touch is the wire key (underscored so it can never collide with a model attribute) — the schema splats it back as this keyword.



119
120
121
122
123
124
# File 'lib/forms/live.rb', line 119

def validate(_touch: nil, **posted) # rubocop:disable Lint/UnderscorePrefixedVariableName
  @touched |= [_touch.to_s] if _touch
  assign_live_attributes(posted[self.class.live_scope.to_sym])
  @model.validate
  reply.morph
end