Module: Phlex::Reactive::Component
- Extended by:
- ActiveSupport::Concern
- Defined in:
- lib/phlex/reactive/component.rb
Overview
Component turns a self-contained Phlex component into a Livewire-style reactive unit: declare actions in Ruby, and the generic ‘reactive` Stimulus controller wires clicks/inputs to an HTTP round trip that re-renders the component and applies it back into the DOM (a plain replace by default; return Response.morph(self) to morph in place and keep the focused input — issue #28). No per-feature Stimulus controllers, no hand-picked Turbo targets.
Include alongside Phlex::Reactive::Streamable (which provides #id and the re-render machinery).
Security model (the decisive design choice) ===
We do NOT ship component STATE to the browser (no snapshot). The DOM carries a signed IDENTITY:
* Record-backed (the common case): reactive_record :todo signs the
record's GlobalID. The server re-finds it via GlobalID — the client
can neither forge the component class nor swap the record. State =
the database.
* State-backed (record-less, e.g. a counter): reactive_state :count
signs the listed instance variables. Use when there is genuinely no
record to re-find.
* Both (the inline_edit pattern): reactive_record :record plus
reactive_state :attribute, :editing signs the record's GlobalID AND
the transient mode in one token, so "which field / what mode" survives
every action round trip and stays tamper-proof.
Actions are DEFAULT-DENY: only methods declared with ‘action :name` may be invoked. The signature proves the token is ours, NOT that this user may act — your action must still authorize the record. Action params pass through a declared schema; nothing else reaches the method.
Usage (record-backed):
class Todos::Item < ApplicationComponent
include Phlex::Reactive::Streamable
include Phlex::Reactive::Component
reactive_record :todo
action :toggle
action :rename, params: { title: :string }
def initialize(todo:) = @todo = todo
def id = dom_id(@todo)
def toggle = ((@todo, :update?); @todo.toggle!(:done))
def rename(title:) = ((@todo, :update?); @todo.update!(title:))
def view_template
li(id:, **reactive_attrs) do
(**on(:toggle)) { @todo.done? ? "✓" : "○" }
span { @todo.title }
end
end
end
Defined Under Namespace
Classes: Action
Instance Method Summary collapse
-
#nested_attributes(association, attrs) ⇒ Object
Map a declared nested param onto Rails’ <assoc>_attributes, carrying the existing associated record’s id so accepts_nested_attributes_for matches it IN PLACE instead of building a second one (issue #24).
-
#nested_update!(association, attrs, **extra) ⇒ Object
Map a nested param onto <assoc>_attributes (with id preservation) AND apply it to the component’s record in one call (issue #24).
-
#on(action_name, event: "click", debounce: nil, **params) ⇒ Object
Attributes for an element that triggers an action.
-
#reactive_attrs ⇒ Object
Root-element attributes: marks the element reactive and carries the signed identity token.
-
#reactive_connection_id ⇒ Object
The acting client’s SSE connection id during the current action (nil outside an action, or when the client isn’t subscribed to a stream).
-
#reactive_field(param, **attrs) ⇒ Object
Bind a form control’s ‘name` to an action param so its value travels with the action — instead of hand-writing the magic `name: “value”` on every input and silently getting no params when you forget it (issue #23).
-
#reactive_input(param, **attrs) ⇒ Object
Render an <input> already bound to an action param (issue #23).
-
#reactive_select(param, **attrs, &block) ⇒ Object
Render a <select> bound to an action param (issue #23).
-
#reply ⇒ Object
Subject-bound reply builder — the preferred way to control an action’s reply.
Instance Method Details
#nested_attributes(association, attrs) ⇒ Object
Map a declared nested param onto Rails’ <assoc>_attributes, carrying the existing associated record’s id so accepts_nested_attributes_for matches it IN PLACE instead of building a second one (issue #24). Returns the update hash; pass it to update!:
def save(address:) = nested_update!(:address, address)
The id is only added when the association already exists, so the first save (no associated record yet) creates one cleanly. The given attrs are not mutated.
263 264 265 266 267 268 269 |
# File 'lib/phlex/reactive/component.rb', line 263 def nested_attributes(association, attrs) merged = attrs.dup existing = reactive_record_for_nested.public_send(association) merged[:id] = existing.id if existing {"#{association}_attributes": merged} end |
#nested_update!(association, attrs, **extra) ⇒ Object
Map a nested param onto <assoc>_attributes (with id preservation) AND apply it to the component’s record in one call (issue #24). Extra keyword attributes update alongside the association.
def save(address:, name:) = nested_update!(:address, address, name:)
275 276 277 |
# File 'lib/phlex/reactive/component.rb', line 275 def nested_update!(association, attrs, **extra) reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra) end |
#on(action_name, event: "click", debounce: nil, **params) ⇒ Object
Attributes for an element that triggers an action.
button(**on(:toggle)) { "○" }
form(**on(:save, event: "submit")) { ... }
input(**on(:update, event: "input", debounce: 300)) # live-as-you-type
Extra keyword args become explicit params merged over collected form fields. For click triggers we force type=“button” so a bare button inside a <form> can’t submit it and cause a full-page navigation.
‘debounce:` (milliseconds) coalesces rapid events (e.g. keystrokes on an “input” trigger) into ONE round trip fired after the quiet period — so live-update-as-you-type doesn’t POST per keystroke. A blur flushes a pending dispatch so the last edit is never dropped. Omit it for the immediate-dispatch default.
214 215 216 217 218 219 220 221 222 223 224 225 |
# File 'lib/phlex/reactive/component.rb', line 214 def on(action_name, event: "click", debounce: nil, **params) attrs = { data: { action: "#{event}->reactive#dispatch", reactive_action_param: action_name.to_s, reactive_params_param: params.to_json } } attrs[:data][:reactive_debounce_param] = debounce if debounce attrs[:type] = "button" if event == "click" attrs end |
#reactive_attrs ⇒ Object
Root-element attributes: marks the element reactive and carries the signed identity token. Spread onto the root:
div(id:, **reactive_attrs) { ... }
191 192 193 194 195 196 197 198 |
# File 'lib/phlex/reactive/component.rb', line 191 def reactive_attrs { data: { controller: "reactive", reactive_token_value: reactive_token } } end |
#reactive_connection_id ⇒ Object
The acting client’s SSE connection id during the current action (nil outside an action, or when the client isn’t subscribed to a stream). Pass it as ‘exclude:` when broadcasting from an action so the actor doesn’t receive the echo of its own change — it already gets the action’s HTTP response:
def (body:)
msg = ChatMessage.create!(room: @room, body:)
ChatMessage::Item.broadcast_append_to("chat", @room,
target: "messages", model: msg, exclude: reactive_connection_id)
end
168 169 170 |
# File 'lib/phlex/reactive/component.rb', line 168 def reactive_connection_id Phlex::Reactive.current_connection_id end |
#reactive_field(param, **attrs) ⇒ Object
Bind a form control’s ‘name` to an action param so its value travels with the action — instead of hand-writing the magic `name: “value”` on every input and silently getting no params when you forget it (issue #23). Returns a Phlex attributes hash to spread onto any control:
input(**reactive_field(:value, value: @record.name))
select(**reactive_field(:status)) { ... }
Extra attrs merge over the binding; an explicit name: still wins (escape hatch). The trigger (on(:save)) stays on the button, not the field — so focusing the input doesn’t dispatch and collapse edit mode.
236 237 238 |
# File 'lib/phlex/reactive/component.rb', line 236 def reactive_field(param, **attrs) {name: param.to_s, **attrs} end |
#reactive_input(param, **attrs) ⇒ Object
Render an <input> already bound to an action param (issue #23). Sugar for input(**reactive_field(param, **attrs)); the value/type/etc. pass through.
reactive_input(:value, value: @record.name, type: "text")
243 244 245 |
# File 'lib/phlex/reactive/component.rb', line 243 def reactive_input(param, **attrs) input(**reactive_field(param, **attrs)) end |
#reactive_select(param, **attrs, &block) ⇒ Object
Render a <select> bound to an action param (issue #23). The options block is the element’s content, so the awkward FormBuilder positional split (where name: lands after the options/html-options args) goes away:
reactive_select(:status) { @statuses.each { |s| option(value: s, selected: s == @record.status) { s } } }
251 252 253 |
# File 'lib/phlex/reactive/component.rb', line 251 def reactive_select(param, **attrs, &block) select(**reactive_field(param, **attrs), &block) end |
#reply ⇒ Object
Subject-bound reply builder — the preferred way to control an action’s reply. ‘reply.replace.flash(:error, msg)` reads cleaner than `Phlex::Reactive::Response.replace(self).flash(:error, msg)`: the component is the implicit subject (no `self` to thread) and there’s no constant to qualify (reply is a method, so a namespaced component needs no ‘Response = …` alias). It returns the same immutable Response the endpoint reads, so chaining and the legacy return-value contract are unchanged. See Phlex::Reactive::Reply.
def archive = reply.remove
def go_home = reply.redirect("/todos")
def update(name:) = (@account.update!(name:); reply.morph)
184 185 186 |
# File 'lib/phlex/reactive/component.rb', line 184 def reply Phlex::Reactive::Reply.new(self) end |