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, CollectionDefinition
Constant Summary collapse
- EMPTY_PARAMS_JSON =
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
"{}"
Instance Method Summary collapse
-
#nested_attributes(association, attrs) ⇒ Object
Map a declared nested param onto Rails'
_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
_attributes (with id preservation) AND apply it to the component's record in one call (issue #24). - #on(action_name, event: "click", debounce: nil, confirm: nil, **params) ⇒ Object
-
#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
nameto an action param so its value travels with the action — instead of hand-writing the magicname: "value"on every input and silently getting no params when you forget it (issue #23). -
#reactive_input(param, **attrs) ⇒ Object
Render an already bound to an action param (issue #23).
-
#reactive_root(**overrides) ⇒ Object
The WHOLE reactive root in one spread (issue #48).
-
#reactive_select(param, **attrs) ⇒ Object
Render a
-
#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'
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.
372 373 374 375 376 377 378 |
# File 'lib/phlex/reactive/component.rb', line 372 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
384 385 386 |
# File 'lib/phlex/reactive/component.rb', line 384 def nested_update!(association, attrs, **extra) reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra) end |
#on(action_name, event: "click", debounce: nil, confirm: nil, **params) ⇒ Object
322 323 324 325 326 327 328 329 330 331 332 333 334 |
# File 'lib/phlex/reactive/component.rb', line 322 def on(action_name, event: "click", debounce: nil, confirm: nil, **params) attrs = { data: { action: "#{event}->reactive#dispatch", reactive_action_param: action_name.to_s, reactive_params_param: params.empty? ? EMPTY_PARAMS_JSON : params.to_json } } attrs[:data][:reactive_debounce_param] = debounce if debounce attrs[:data][:reactive_confirm_param] = confirm if confirm 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) { ... }
264 265 266 267 268 269 270 271 |
# File 'lib/phlex/reactive/component.rb', line 264 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
241 242 243 |
# File 'lib/phlex/reactive/component.rb', line 241 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.
345 346 347 |
# File 'lib/phlex/reactive/component.rb', line 345 def reactive_field(param, **attrs) { name: param.to_s, **attrs } end |
#reactive_input(param, **attrs) ⇒ Object
Render an 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")
352 353 354 |
# File 'lib/phlex/reactive/component.rb', line 352 def reactive_input(param, **attrs) input(**reactive_field(param, **attrs)) end |
#reactive_root(**overrides) ⇒ Object
The WHOLE reactive root in one spread (issue #48). reactive_attrs alone
doesn't emit id:, so id: and data-controller="reactive" can land on
DIFFERENT elements — putting id: on a child leaves the controller root's
id empty, which silently breaks token threading (the client self-matches
its next token by this.element.id) and 403s on the next action.
reactive_root binds the id to the SAME element as reactive_attrs, so the footgun is unbuildable:
div(**reactive_root) { ... } # id + controller + token
div(**reactive_root(class: "card")) { ... } # add your own attrs
mix deep-merges, so overrides add class:/data: without clobbering the
controller/token data: (a bare data: would). The id is resolved separately
(an explicit override wins as a clean replace, not a mix string-concat —
mix would join two String ids into "default override").
288 289 290 291 |
# File 'lib/phlex/reactive/component.rb', line 288 def reactive_root(**overrides) root_id = overrides.delete(:id) || id mix({ **reactive_attrs }, overrides, { id: root_id }) end |
#reactive_select(param, **attrs) ⇒ Object
Render a
reactive_select(:status) { @statuses.each { |s| option(value: s, selected: s == @record.status) { s } } }
360 361 362 |
# File 'lib/phlex/reactive/component.rb', line 360 def reactive_select(param, **attrs, &) select(**reactive_field(param, **attrs), &) 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)
257 258 259 |
# File 'lib/phlex/reactive/component.rb', line 257 def reply Phlex::Reactive::Reply.new(self) end |