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.
204 205 206 207 208 209 210 211 212 213 214 215 |
# File 'lib/forms/live.rb', line 204 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.
179 180 181 182 183 184 185 186 |
# File 'lib/forms/live.rb', line 179 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
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 |
# File 'lib/forms/live.rb', line 160 def form_attributes attrs = super attrs = mix( attrs, reactive_root(id: attrs[:id] || id), on(:validate, event: "input", debounce: self.class.live_debounce) ) # Hoist a declared tag field's wire attrs onto the form root so the rootless # widget (rendered in the block) is driven by this root, which then DOM-owns # its hidden field. Name/id derived through field_name/field_id — the same # path the rootless render uses, so the [name=…]/#…_query selectors match. tag = self.class. return attrs unless tag mix(attrs, Forms::TagField.root_tag_attributes(name: field_name(tag[:name]), id: field_id(tag[:name]))) end |
#id ⇒ Object
Streamable's #id contract: the reactive root's stable DOM id.
141 142 143 |
# File 'lib/forms/live.rb', line 141 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).
130 131 132 133 134 135 136 137 138 |
# File 'lib/forms/live.rb', line 130 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
192 193 194 195 196 |
# File 'lib/forms/live.rb', line 192 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
198 199 200 |
# File 'lib/forms/live.rb', line 198 def signed_model_gid? @model.respond_to?(:to_gid) && @model.respond_to?(:persisted?) && @model.persisted? end |
#touched?(name) ⇒ Boolean
188 189 190 |
# File 'lib/forms/live.rb', line 188 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.
149 150 151 152 153 154 |
# File 'lib/forms/live.rb', line 149 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 |