phlex-reactive 0.4.8 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +924 -4
- data/README.md +986 -32
- data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
- data/app/javascript/phlex/reactive/compute.js +52 -7
- data/app/javascript/phlex/reactive/compute.min.js +4 -0
- data/app/javascript/phlex/reactive/compute.min.js.map +10 -0
- data/app/javascript/phlex/reactive/confirm.min.js +4 -0
- data/app/javascript/phlex/reactive/confirm.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +1487 -80
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -0
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -0
- data/lib/generators/phlex/reactive/component/USAGE +2 -1
- data/lib/generators/phlex/reactive/component/templates/component.rb.erb +1 -2
- data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +33 -2
- data/lib/generators/phlex/reactive/install/install_generator.rb +9 -3
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +37 -0
- data/lib/phlex/reactive/component/dsl.rb +249 -0
- data/lib/phlex/reactive/component/helpers.rb +595 -0
- data/lib/phlex/reactive/component/identity.rb +92 -0
- data/lib/phlex/reactive/component/registry.rb +200 -0
- data/lib/phlex/reactive/component.rb +30 -442
- data/lib/phlex/reactive/doctor.rb +333 -0
- data/lib/phlex/reactive/engine.rb +27 -9
- data/lib/phlex/reactive/js.rb +249 -0
- data/lib/phlex/reactive/log_subscriber.rb +64 -0
- data/lib/phlex/reactive/param_schema.rb +390 -0
- data/lib/phlex/reactive/reply.rb +5 -3
- data/lib/phlex/reactive/response.rb +160 -16
- data/lib/phlex/reactive/stream.rb +98 -0
- data/lib/phlex/reactive/streamable.rb +307 -43
- data/lib/phlex/reactive/test_helpers/matchers.rb +112 -0
- data/lib/phlex/reactive/test_helpers.rb +308 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +333 -14
- data/lib/tasks/phlex_reactive.rake +14 -0
- metadata +19 -1
|
@@ -10,8 +10,10 @@ module Phlex
|
|
|
10
10
|
# focused input — issue #28). No per-feature Stimulus controllers, no
|
|
11
11
|
# hand-picked Turbo targets.
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
13
|
+
# Including Component pulls in Phlex::Reactive::Streamable automatically
|
|
14
|
+
# (Concern dependency — Streamable lands on the base first, exactly the old
|
|
15
|
+
# manual order), so ONE include is enough. The legacy explicit double
|
|
16
|
+
# include remains a harmless no-op.
|
|
15
17
|
#
|
|
16
18
|
# === Security model (the decisive design choice) ===
|
|
17
19
|
# We do NOT ship component STATE to the browser (no snapshot). The DOM
|
|
@@ -34,9 +36,8 @@ module Phlex
|
|
|
34
36
|
# act — your action must still authorize the record. Action params pass
|
|
35
37
|
# through a declared schema; nothing else reaches the method.
|
|
36
38
|
#
|
|
37
|
-
# Usage (record-backed):
|
|
39
|
+
# Usage (record-backed — #id defaults to dom_id(@todo), issue #81):
|
|
38
40
|
# class Todos::Item < ApplicationComponent
|
|
39
|
-
# include Phlex::Reactive::Streamable
|
|
40
41
|
# include Phlex::Reactive::Component
|
|
41
42
|
#
|
|
42
43
|
# reactive_record :todo
|
|
@@ -44,7 +45,6 @@ module Phlex
|
|
|
44
45
|
# action :rename, params: { title: :string }
|
|
45
46
|
#
|
|
46
47
|
# def initialize(todo:) = @todo = todo
|
|
47
|
-
# def id = dom_id(@todo)
|
|
48
48
|
#
|
|
49
49
|
# def toggle = (authorize!(@todo, :update?); @todo.toggle!(:done))
|
|
50
50
|
# def rename(title:) = (authorize!(@todo, :update?); @todo.update!(title:))
|
|
@@ -56,18 +56,37 @@ module Phlex
|
|
|
56
56
|
# end
|
|
57
57
|
# end
|
|
58
58
|
# end
|
|
59
|
+
#
|
|
60
|
+
# Assembled from three cohesive concerns (issue #115) — one include, zero
|
|
61
|
+
# public API change:
|
|
62
|
+
# * Component::DSL — the five declaration registries (via
|
|
63
|
+
# Component::Registry) + from_identity
|
|
64
|
+
# * Component::Identity — reactive_token + the hot-path ivar precomputation
|
|
65
|
+
# * Component::Helpers — reply/js, reactive_attrs/root, on/on_client,
|
|
66
|
+
# the field/select/text bindings, and the nested-attributes helpers
|
|
59
67
|
module Component
|
|
60
68
|
extend ActiveSupport::Concern
|
|
69
|
+
include Phlex::Reactive::Streamable
|
|
61
70
|
|
|
62
|
-
# A declared, client-invokable action and its param schema.
|
|
63
|
-
|
|
71
|
+
# A declared, client-invokable action and its param schema. `params` keeps
|
|
72
|
+
# the RAW declared hash (the readable form callers/specs inspect); `schema`
|
|
73
|
+
# is the compiled Phlex::Reactive::ParamSchema (issue #109) the endpoint
|
|
74
|
+
# coerces through — built ONCE at declaration so a typo'd type symbol
|
|
75
|
+
# raises Phlex::Reactive::UnknownParamType at class load, not at click time.
|
|
76
|
+
Action = Data.define(:name, :params, :schema)
|
|
64
77
|
|
|
65
78
|
# A declared client-side computation (data binding). `inputs`/`outputs` are
|
|
66
79
|
# the action-param names of the fields the reducer reads/writes; `reducer`
|
|
67
80
|
# is the key a JS function is registered under (Reactive.compute(key, fn)).
|
|
68
81
|
# The generic controller runs the reducer on `input` — writing outputs with
|
|
69
82
|
# NO round trip — then the debounced POST reconciles from the server reply.
|
|
70
|
-
|
|
83
|
+
#
|
|
84
|
+
# `input_types` (issue #104) is nil for the ARRAY input form (untyped ⇒ the
|
|
85
|
+
# client coerces every input through Number, the shipped behavior) and a
|
|
86
|
+
# { name => type } hash for the typed HASH form (:string reads the field
|
|
87
|
+
# value raw, :number coerces). `inputs` stays the ordered name list either
|
|
88
|
+
# way, so iteration order is preserved and the array-form wire is unchanged.
|
|
89
|
+
ComputeDefinition = Data.define(:name, :inputs, :outputs, :reducer, :input_types)
|
|
71
90
|
|
|
72
91
|
# A declared add/remove-row collection (issue #35): the list contract tied
|
|
73
92
|
# into one unit — the per-row item component, the container DOM id rows
|
|
@@ -87,440 +106,9 @@ module Phlex
|
|
|
87
106
|
end
|
|
88
107
|
end
|
|
89
108
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
# re-finds it on each action. State lives in the DB.
|
|
94
|
-
def reactive_record(name)
|
|
95
|
-
@reactive_record_key = name.to_sym
|
|
96
|
-
remove_instance_variable(:@reactive_record_ivar) if defined?(@reactive_record_ivar)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def reactive_record_key
|
|
100
|
-
return @reactive_record_key if defined?(@reactive_record_key)
|
|
101
|
-
|
|
102
|
-
superclass.respond_to?(:reactive_record_key) ? superclass.reactive_record_key : nil
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
# Opt into signed STATE for record-less components only.
|
|
106
|
-
# reactive_state :count, :open
|
|
107
|
-
def reactive_state(*names)
|
|
108
|
-
reactive_state_keys.concat(names.map(&:to_sym))
|
|
109
|
-
@reactive_state_ivars = nil # rebuild the cached [key, ivar] pairs
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def reactive_state_keys
|
|
113
|
-
@reactive_state_keys ||= (superclass.respond_to?(:reactive_state_keys) ? superclass.reactive_state_keys.dup : [])
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
# Declare a client-invokable action with an optional param schema.
|
|
117
|
-
# action :increment
|
|
118
|
-
# action :rename, params: { title: :string }
|
|
119
|
-
#
|
|
120
|
-
# Param types are coerced server-side; anything not in the schema is
|
|
121
|
-
# dropped before reaching your method (no mass assignment):
|
|
122
|
-
# * Scalars — :string (default), :integer, :float, :boolean
|
|
123
|
-
# * Array of scalar — wrap the type in an array: [:integer]
|
|
124
|
-
# * Array of hash (Rails nested attributes) — wrap a hash schema:
|
|
125
|
-
# action :save, params: {
|
|
126
|
-
# date: :string,
|
|
127
|
-
# bank_account_ids: [:integer],
|
|
128
|
-
# invoice_items_attributes: [
|
|
129
|
-
# { id: :integer, quantity: :float, price: :float, _destroy: :boolean }
|
|
130
|
-
# ]
|
|
131
|
-
# }
|
|
132
|
-
# Array params accept BOTH a JSON array and a Rails-style index hash
|
|
133
|
-
# ({ "0" => ..., "1" => ... }), so a fields_for collection works either way.
|
|
134
|
-
def action(name, params: {})
|
|
135
|
-
reactive_actions[name.to_sym] = Action.new(name: name.to_sym, params: params)
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def reactive_actions
|
|
139
|
-
@reactive_actions ||= (superclass.respond_to?(:reactive_actions) ? superclass.reactive_actions.dup : {})
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def reactive_action(name)
|
|
143
|
-
reactive_actions[name.to_sym]
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def reactive_action?(name)
|
|
147
|
-
reactive_actions.key?(name.to_sym)
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
# Declare an add/remove-row collection (issue #35) — the list contract
|
|
151
|
-
# in one place, so actions append/prepend/remove a row WITHOUT
|
|
152
|
-
# re-deriving the container id, count, and empty-state in every action:
|
|
153
|
-
#
|
|
154
|
-
# reactive_collection :items,
|
|
155
|
-
# item: ItemRowComponent, # the per-row Streamable component
|
|
156
|
-
# container: "items-list", # the DOM id rows live in
|
|
157
|
-
# count: "items-count", # optional companion id (the size badge)
|
|
158
|
-
# empty: ItemsEmptyComponent, # optional empty-state component
|
|
159
|
-
# size: -> { @record.items.size } # resolves the live size
|
|
160
|
-
#
|
|
161
|
-
# An action then governs the reply with one call:
|
|
162
|
-
# reply.append(:items, item) # row append + count + clear empty-state
|
|
163
|
-
# reply.remove(:items, id) # row remove + count + restore empty-state
|
|
164
|
-
#
|
|
165
|
-
# count/empty/size are optional: a list with just rows omits them and the
|
|
166
|
-
# corresponding stream isn't emitted. See Phlex::Reactive::Reply and the
|
|
167
|
-
# README "Reactive collections" section.
|
|
168
|
-
def reactive_collection(name, item:, container:, count: nil, empty: nil, size: nil)
|
|
169
|
-
reactive_collections[name.to_sym] =
|
|
170
|
-
CollectionDefinition.new(name: name.to_sym, item:, container:, count:, empty:, size:)
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def reactive_collections
|
|
174
|
-
@reactive_collections ||= superclass.respond_to?(:reactive_collections) ? superclass.reactive_collections.dup : {}
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def reactive_collection_def(name)
|
|
178
|
-
reactive_collections[name.to_sym]
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def reactive_collection?(name)
|
|
182
|
-
reactive_collections.key?(name.to_sym)
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
# Declare a client-side computation, OR (called with just a name) read
|
|
186
|
-
# one back. Dual-purpose so a component reads `reactive_compute :split,
|
|
187
|
-
# inputs: …, outputs: …` and the endpoint/helpers read
|
|
188
|
-
# `reactive_compute(:split)` — mirroring how `on`/`reactive_field` keep a
|
|
189
|
-
# tight surface. `reducer:` defaults to the compute name.
|
|
190
|
-
#
|
|
191
|
-
# reactive_compute :payment_split,
|
|
192
|
-
# inputs: %i[allowance cash leasing total], # fields the JS reducer reads
|
|
193
|
-
# outputs: %i[allowance cash leasing] # fields it writes (no round trip)
|
|
194
|
-
#
|
|
195
|
-
# Register the matching JS once at boot:
|
|
196
|
-
# import { setComputeReducer } from "phlex/reactive/compute"
|
|
197
|
-
# setComputeReducer("payment_split", ({ allowance, cash, leasing, total }) => ({ … }))
|
|
198
|
-
def reactive_compute(name, inputs: nil, outputs: nil, reducer: nil)
|
|
199
|
-
return reactive_computes[name.to_sym] if inputs.nil? && outputs.nil?
|
|
200
|
-
|
|
201
|
-
reactive_computes[name.to_sym] = ComputeDefinition.new(
|
|
202
|
-
name: name.to_sym, inputs: Array(inputs).map(&:to_sym),
|
|
203
|
-
outputs: Array(outputs).map(&:to_sym), reducer: (reducer || name).to_s
|
|
204
|
-
)
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
def reactive_computes
|
|
208
|
-
@reactive_computes ||= superclass.respond_to?(:reactive_computes) ? superclass.reactive_computes.dup : {}
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
def reactive_compute?(name)
|
|
212
|
-
reactive_computes.key?(name.to_sym)
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
# The record's instance-variable symbol (e.g. :@todo), computed once.
|
|
216
|
-
# reactive_token reads it on every render; interpolating :"@#{key}" each
|
|
217
|
-
# time would allocate a symbol per render. Nil when record-less. Memoized
|
|
218
|
-
# per class; reset alongside reactive_record so it can't go stale.
|
|
219
|
-
def reactive_record_ivar
|
|
220
|
-
return @reactive_record_ivar if defined?(@reactive_record_ivar)
|
|
221
|
-
|
|
222
|
-
@reactive_record_ivar = reactive_record_key ? :"@#{reactive_record_key}" : nil
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
# [string_key, ivar_symbol] pairs for the signed state, computed once.
|
|
226
|
-
# reactive_token walks these every render; precomputing the "count"/:@count
|
|
227
|
-
# forms avoids a String + Symbol allocation per key per render. Memoized
|
|
228
|
-
# per class; reset when reactive_state adds a key.
|
|
229
|
-
def reactive_state_ivars
|
|
230
|
-
@reactive_state_ivars ||= reactive_state_keys.map { [it.to_s, :"@#{it}"] }
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
# Rebuild a component instance from a verified identity payload. Called
|
|
234
|
-
# by the action endpoint after the token signature is verified.
|
|
235
|
-
#
|
|
236
|
-
# A component may carry a record (re-found via GlobalID), signed state
|
|
237
|
-
# (instance vars listed in reactive_state), or BOTH (the inline_edit
|
|
238
|
-
# pattern: a record plus "which field / what mode"). We assemble the
|
|
239
|
-
# init kwargs from whichever identity pieces are declared.
|
|
240
|
-
def from_identity(payload)
|
|
241
|
-
kwargs = {}
|
|
242
|
-
|
|
243
|
-
if reactive_record_key
|
|
244
|
-
record = GlobalID::Locator.locate(payload.fetch("gid"))
|
|
245
|
-
raise(ActiveRecord::RecordNotFound, "reactive record missing") unless record
|
|
246
|
-
|
|
247
|
-
kwargs[reactive_record_key] = record
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
if reactive_state_keys.any?
|
|
251
|
-
state = payload.fetch("s", {})
|
|
252
|
-
reactive_state_keys.each do
|
|
253
|
-
# Use key presence, not the value: a signed `nil` (nullable state)
|
|
254
|
-
# must round-trip distinctly. Only a genuinely absent key falls
|
|
255
|
-
# back to the component's initialize default; `false` and `nil`
|
|
256
|
-
# both survive.
|
|
257
|
-
next unless state.key?(it.to_s)
|
|
258
|
-
|
|
259
|
-
kwargs[it] = state[it.to_s]
|
|
260
|
-
end
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
new(**kwargs)
|
|
264
|
-
end
|
|
265
|
-
end
|
|
266
|
-
|
|
267
|
-
# The acting client's SSE connection id during the current action (nil
|
|
268
|
-
# outside an action, or when the client isn't subscribed to a stream).
|
|
269
|
-
# Pass it as `exclude:` when broadcasting from an action so the actor
|
|
270
|
-
# doesn't receive the echo of its own change — it already gets the
|
|
271
|
-
# action's HTTP response:
|
|
272
|
-
#
|
|
273
|
-
# def send_message(body:)
|
|
274
|
-
# msg = ChatMessage.create!(room: @room, body:)
|
|
275
|
-
# ChatMessage::Item.broadcast_append_to("chat", @room,
|
|
276
|
-
# target: "messages", model: msg, exclude: reactive_connection_id)
|
|
277
|
-
# end
|
|
278
|
-
def reactive_connection_id
|
|
279
|
-
Phlex::Reactive.current_connection_id
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
# Subject-bound reply builder — the preferred way to control an action's
|
|
283
|
-
# reply. `reply.replace.flash(:error, msg)` reads cleaner than
|
|
284
|
-
# `Phlex::Reactive::Response.replace(self).flash(:error, msg)`: the
|
|
285
|
-
# component is the implicit subject (no `self` to thread) and there's no
|
|
286
|
-
# constant to qualify (reply is a method, so a namespaced component needs
|
|
287
|
-
# no `Response = …` alias). It returns the same immutable Response the
|
|
288
|
-
# endpoint reads, so chaining and the legacy return-value contract are
|
|
289
|
-
# unchanged. See Phlex::Reactive::Reply.
|
|
290
|
-
#
|
|
291
|
-
# def archive = reply.remove
|
|
292
|
-
# def go_home = reply.redirect("/todos")
|
|
293
|
-
# def update(name:) = (@account.update!(name:); reply.morph)
|
|
294
|
-
def reply
|
|
295
|
-
Phlex::Reactive::Reply.new(self)
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
# Root-element attributes: marks the element reactive and carries the
|
|
299
|
-
# signed identity token. Spread onto the root:
|
|
300
|
-
# div(id:, **reactive_attrs) { ... }
|
|
301
|
-
def reactive_attrs
|
|
302
|
-
{
|
|
303
|
-
data: {
|
|
304
|
-
controller: "reactive",
|
|
305
|
-
reactive_token_value: reactive_token
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
end
|
|
309
|
-
|
|
310
|
-
# The WHOLE reactive root in one spread (issue #48). reactive_attrs alone
|
|
311
|
-
# doesn't emit `id:`, so `id:` and `data-controller="reactive"` can land on
|
|
312
|
-
# DIFFERENT elements — putting `id:` on a child leaves the controller root's
|
|
313
|
-
# `id` empty, which silently breaks token threading (the client self-matches
|
|
314
|
-
# its next token by `this.element.id`) and 403s on the next action.
|
|
315
|
-
#
|
|
316
|
-
# reactive_root binds the id to the SAME element as reactive_attrs, so the
|
|
317
|
-
# footgun is unbuildable:
|
|
318
|
-
# div(**reactive_root) { ... } # id + controller + token
|
|
319
|
-
# div(**reactive_root(class: "card")) { ... } # add your own attrs
|
|
320
|
-
#
|
|
321
|
-
# mix deep-merges, so overrides add `class:`/`data:` without clobbering the
|
|
322
|
-
# controller/token data: (a bare data: would). The id is resolved separately
|
|
323
|
-
# (an explicit override wins as a clean replace, not a `mix` string-concat —
|
|
324
|
-
# mix would join two String ids into "default override").
|
|
325
|
-
def reactive_root(**overrides)
|
|
326
|
-
root_id = overrides.delete(:id) || id
|
|
327
|
-
mix({ **reactive_attrs }, overrides, { id: root_id })
|
|
328
|
-
end
|
|
329
|
-
|
|
330
|
-
# Attributes for an element that triggers an action.
|
|
331
|
-
# button(**on(:toggle)) { "○" }
|
|
332
|
-
# form(**on(:save, event: "submit")) { ... }
|
|
333
|
-
# input(**on(:update, event: "input", debounce: 300)) # live-as-you-type
|
|
334
|
-
#
|
|
335
|
-
# Extra keyword args become explicit params merged over collected form
|
|
336
|
-
# fields. For click triggers we force type="button" so a bare button
|
|
337
|
-
# inside a <form> can't submit it and cause a full-page navigation.
|
|
338
|
-
#
|
|
339
|
-
# `debounce:` (milliseconds) coalesces rapid events (e.g. keystrokes on an
|
|
340
|
-
# "input" trigger) into ONE round trip fired after the quiet period — so
|
|
341
|
-
# live-update-as-you-type doesn't POST per keystroke. A blur flushes a
|
|
342
|
-
# pending dispatch so the last edit is never dropped. Omit it for the
|
|
343
|
-
# immediate-dispatch default.
|
|
344
|
-
#
|
|
345
|
-
# `confirm:` (a message string) gates the action behind a confirmation
|
|
346
|
-
# prompt (issue #52). Destructive reactive triggers can't use Hotwire's
|
|
347
|
-
# `data-turbo-confirm` — the reactive controller calls preventDefault and
|
|
348
|
-
# enqueues the POST itself, so Turbo's confirm handling never runs. The
|
|
349
|
-
# client shows window.confirm(message) FIRST and bails before any
|
|
350
|
-
# enqueue/debounce if the user declines (and prevents the native default so
|
|
351
|
-
# a `submit` trigger can't navigate on cancel). Omit it for no prompt.
|
|
352
|
-
# button(**on(:destroy, confirm: "Really delete this item?")) { "Delete" }
|
|
353
|
-
#
|
|
354
|
-
# `event:` is interpolated verbatim into the Stimulus action descriptor
|
|
355
|
-
# (`#{event}->reactive#dispatch`), so any Stimulus event string works —
|
|
356
|
-
# including its native KEYBOARD FILTERS. Pass `event: "keydown.enter"` for
|
|
357
|
-
# Enter-to-submit or `event: "keydown.esc"` for Escape-to-cancel, and the
|
|
358
|
-
# action fires only on that key — no separate option, no client code, and
|
|
359
|
-
# `key` stays free as an ordinary action-param name (on(:switch, key: …)):
|
|
360
|
-
# input(**on(:add, event: "keydown.enter")) # Enter submits
|
|
361
|
-
# button(**on(:cancel, event: "keydown.esc")) # Escape cancels
|
|
362
|
-
#
|
|
363
|
-
# `listnav:` (a CSS selector for the option elements) adds keyboard list
|
|
364
|
-
# navigation to a search/combobox trigger (issue #72). It appends Stimulus
|
|
365
|
-
# keyboard filters to the SAME element's data-action so Arrow Up/Down move a
|
|
366
|
-
# client-side highlight among the options, Enter picks the highlighted one
|
|
367
|
-
# (clicking its own reactive trigger — so selection stays a signed action),
|
|
368
|
-
# and Escape clears — all with NO server round trip for the highlight (the
|
|
369
|
-
# controller's listnav* handlers, like #recompute). Omit it for no nav.
|
|
370
|
-
# input(**on(:search, event: "input", debounce: 300, listnav: "[role=option]"))
|
|
371
|
-
# The verbatim JSON for an empty explicit-params payload. The common
|
|
372
|
-
# trigger (on(:increment), no params) hits this on EVERY render — skipping
|
|
373
|
-
# params.to_json (which re-serializes {} to the same "{}" each time) avoids
|
|
374
|
-
# a per-render allocation while keeping the wire format byte-identical.
|
|
375
|
-
EMPTY_PARAMS_JSON = "{}"
|
|
376
|
-
|
|
377
|
-
# The keyboard filters appended to a listnav trigger's data-action. Each is
|
|
378
|
-
# a client-only handler (no POST) except Enter, which clicks the highlighted
|
|
379
|
-
# option's own reactive trigger. Stimulus binds these natively.
|
|
380
|
-
LISTNAV_ACTIONS = [
|
|
381
|
-
"keydown.down->reactive#listnavNext",
|
|
382
|
-
"keydown.up->reactive#listnavPrev",
|
|
383
|
-
"keydown.enter->reactive#listnavPick",
|
|
384
|
-
"keydown.esc->reactive#listnavClose"
|
|
385
|
-
].freeze
|
|
386
|
-
|
|
387
|
-
def on(action_name, event: "click", debounce: nil, confirm: nil, listnav: nil, **params)
|
|
388
|
-
action = "#{event}->reactive#dispatch"
|
|
389
|
-
action = "#{action} #{LISTNAV_ACTIONS.join(" ")}" if listnav
|
|
390
|
-
attrs = {
|
|
391
|
-
data: {
|
|
392
|
-
action:,
|
|
393
|
-
reactive_action_param: action_name.to_s,
|
|
394
|
-
reactive_params_param: params.empty? ? EMPTY_PARAMS_JSON : params.to_json
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
attrs[:data][:reactive_debounce_param] = debounce if debounce
|
|
398
|
-
attrs[:data][:reactive_confirm_param] = confirm if confirm
|
|
399
|
-
attrs[:data][:reactive_listnav_option_param] = listnav if listnav
|
|
400
|
-
attrs[:type] = "button" if event == "click"
|
|
401
|
-
attrs
|
|
402
|
-
end
|
|
403
|
-
|
|
404
|
-
# Bind a form control's `name` to an action param so its value travels with
|
|
405
|
-
# the action — instead of hand-writing the magic `name: "value"` on every
|
|
406
|
-
# input and silently getting no params when you forget it (issue #23).
|
|
407
|
-
# Returns a Phlex attributes hash to spread onto any control:
|
|
408
|
-
# input(**reactive_field(:value, value: @record.name))
|
|
409
|
-
# select(**reactive_field(:status)) { ... }
|
|
410
|
-
# Extra attrs merge over the binding; an explicit name: still wins (escape
|
|
411
|
-
# hatch). The trigger (on(:save)) stays on the button, not the field — so
|
|
412
|
-
# focusing the input doesn't dispatch and collapse edit mode.
|
|
413
|
-
def reactive_field(param, **attrs)
|
|
414
|
-
{ name: param.to_s, **attrs }
|
|
415
|
-
end
|
|
416
|
-
|
|
417
|
-
# Render an <input> already bound to an action param (issue #23). Sugar for
|
|
418
|
-
# input(**reactive_field(param, **attrs)); the value/type/etc. pass through.
|
|
419
|
-
# reactive_input(:value, value: @record.name, type: "text")
|
|
420
|
-
def reactive_input(param, **attrs)
|
|
421
|
-
input(**reactive_field(param, **attrs))
|
|
422
|
-
end
|
|
423
|
-
|
|
424
|
-
# Data attributes declaring a client-side compute for the root element.
|
|
425
|
-
# Spread ALONGSIDE reactive_root so the generic controller can find the
|
|
426
|
-
# reducer and the named input/output fields inside this root:
|
|
427
|
-
# div(**mix(reactive_root, reactive_compute_attrs(:payment_split))) { … }
|
|
428
|
-
#
|
|
429
|
-
# It emits the reducer key plus the input/output field names as JSON so the
|
|
430
|
-
# client runs the reducer on `input`, writes the outputs with no round trip,
|
|
431
|
-
# then the debounced POST reconciles from the server reply. Raises for an
|
|
432
|
-
# undeclared compute — a silent no-op would leave the field wiring dead.
|
|
433
|
-
def reactive_compute_attrs(name)
|
|
434
|
-
definition = self.class.reactive_compute(name)
|
|
435
|
-
raise Error, "#{self.class} has no reactive_compute #{name.inspect}" unless definition
|
|
436
|
-
|
|
437
|
-
{
|
|
438
|
-
data: {
|
|
439
|
-
reactive_compute_reducer_param: definition.reducer,
|
|
440
|
-
reactive_compute_inputs_param: definition.inputs.map(&:to_s).to_json,
|
|
441
|
-
reactive_compute_outputs_param: definition.outputs.map(&:to_s).to_json
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
end
|
|
445
|
-
|
|
446
|
-
# Render a <select> bound to an action param (issue #23). The options block
|
|
447
|
-
# is the element's content, so the awkward FormBuilder positional split
|
|
448
|
-
# (where name: lands after the options/html-options args) goes away:
|
|
449
|
-
# reactive_select(:status) { @statuses.each { |s| option(value: s, selected: s == @record.status) { s } } }
|
|
450
|
-
def reactive_select(param, **attrs, &)
|
|
451
|
-
select(**reactive_field(param, **attrs), &)
|
|
452
|
-
end
|
|
453
|
-
|
|
454
|
-
# Map a declared nested param onto Rails' <assoc>_attributes, carrying the
|
|
455
|
-
# existing associated record's id so accepts_nested_attributes_for matches
|
|
456
|
-
# it IN PLACE instead of building a second one (issue #24). Returns the
|
|
457
|
-
# update hash; pass it to update!:
|
|
458
|
-
# def save(address:) = nested_update!(:address, address)
|
|
459
|
-
# The id is only added when the association already exists, so the first
|
|
460
|
-
# save (no associated record yet) creates one cleanly. The given attrs are
|
|
461
|
-
# not mutated.
|
|
462
|
-
def nested_attributes(association, attrs)
|
|
463
|
-
merged = attrs.dup
|
|
464
|
-
existing = reactive_record_for_nested.public_send(association)
|
|
465
|
-
merged[:id] = existing.id if existing
|
|
466
|
-
|
|
467
|
-
{ "#{association}_attributes": merged }
|
|
468
|
-
end
|
|
469
|
-
|
|
470
|
-
# Map a nested param onto <assoc>_attributes (with id preservation) AND
|
|
471
|
-
# apply it to the component's record in one call (issue #24). Extra keyword
|
|
472
|
-
# attributes update alongside the association.
|
|
473
|
-
# def save(address:, name:) = nested_update!(:address, address, name:)
|
|
474
|
-
def nested_update!(association, attrs, **extra)
|
|
475
|
-
reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
|
|
476
|
-
end
|
|
477
|
-
|
|
478
|
-
private
|
|
479
|
-
|
|
480
|
-
# The component's record, for the nested-attributes helpers. Requires a
|
|
481
|
-
# declared reactive_record (the nested helper only makes sense for a
|
|
482
|
-
# record-backed component).
|
|
483
|
-
def reactive_record_for_nested
|
|
484
|
-
key = self.class.reactive_record_key
|
|
485
|
-
raise Error, "#{self.class} must declare `reactive_record` to use nested_update!/nested_attributes" unless key
|
|
486
|
-
|
|
487
|
-
instance_variable_get(:"@#{key}")
|
|
488
|
-
end
|
|
489
|
-
|
|
490
|
-
# Signed identity payload: the class name plus whichever identity pieces
|
|
491
|
-
# the component declares — a record GlobalID (`gid`), signed state (`s`),
|
|
492
|
-
# or both. Keeping them in ONE MessageVerifier payload makes the state
|
|
493
|
-
# (e.g. which column an inline_edit may write) tamper-proof alongside the
|
|
494
|
-
# record. Record-only ({c, gid}) and state-only ({c, s}) shapes are
|
|
495
|
-
# unchanged.
|
|
496
|
-
#
|
|
497
|
-
# A record that is NOT YET PERSISTED (new_record?) has no id → no GlobalID
|
|
498
|
-
# (to_gid raises MissingModelIdError). A record-backed component may render
|
|
499
|
-
# such a draft (an unsaved order the user is building): we OMIT gid and rely
|
|
500
|
-
# on the declared state (reactive_state) as the draft seed, so the token
|
|
501
|
-
# still signs cleanly and the client controller mounts. The draft is then
|
|
502
|
-
# driven client-side (reactive_compute) until it's saved; once persisted, a
|
|
503
|
-
# re-render signs the gid as usual. If the record is unsaved AND no state is
|
|
504
|
-
# declared, the token carries just {c} — enough to mount, but with no
|
|
505
|
-
# identity to round-trip, so declare reactive_state for a draft you sync.
|
|
506
|
-
def reactive_token
|
|
507
|
-
klass = self.class
|
|
508
|
-
payload = { "c" => klass.name }
|
|
509
|
-
|
|
510
|
-
if (record_ivar = klass.reactive_record_ivar)
|
|
511
|
-
record = instance_variable_get(record_ivar)
|
|
512
|
-
payload["gid"] = record.to_gid.to_s unless record.respond_to?(:persisted?) && !record.persisted?
|
|
513
|
-
end
|
|
514
|
-
|
|
515
|
-
state_ivars = klass.reactive_state_ivars
|
|
516
|
-
unless state_ivars.empty?
|
|
517
|
-
state = {}
|
|
518
|
-
state_ivars.each { |key, ivar| state[key] = instance_variable_get(ivar).as_json }
|
|
519
|
-
payload["s"] = state
|
|
520
|
-
end
|
|
521
|
-
|
|
522
|
-
Phlex::Reactive.sign(payload)
|
|
523
|
-
end
|
|
109
|
+
include DSL
|
|
110
|
+
include Identity
|
|
111
|
+
include Helpers
|
|
524
112
|
end
|
|
525
113
|
end
|
|
526
114
|
end
|