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
-
.register_param_type! ⇒ Object
Registers the raw-hash param type the :validate schema is declared with.
Instance Method Summary collapse
-
#assign_live_attributes(attrs) ⇒ Object
Whitelisted assignment through public writers.
-
#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.
-
#form_attributes ⇒ Object
The
-
#id ⇒ Object
Streamable's #id contract: the reactive root's stable DOM id.
-
#initialize(*modifiers, model: nil, model_gid: nil, touched: nil) ⇒ Object
Two construction modes: the app renders
new(model:); the endpoint rebuildsnew(model_gid:, touched:)from the signed state (see Component::DSL.from_identity — only declared state keys round-trip). - #locate_live_model(model_gid) ⇒ Object
- #signed_model_gid? ⇒ Boolean
- #touched?(name) ⇒ Boolean
-
#validate(_touch: nil, **posted) ⇒ Object
Assign → validate → morph.
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_attributes ⇒ Object
The
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 |
#id ⇒ Object
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
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
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 |