phlex-reactive 0.2.9 → 0.4.0
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 +220 -0
- data/README.md +161 -30
- data/app/controllers/phlex/reactive/actions_controller.rb +72 -11
- data/app/javascript/phlex/reactive/reactive_controller.js +150 -22
- data/lib/generators/phlex/reactive/install/install_generator.rb +6 -6
- data/lib/phlex/reactive/component.rb +117 -21
- data/lib/phlex/reactive/engine.rb +18 -9
- data/lib/phlex/reactive/reply.rb +102 -0
- data/lib/phlex/reactive/response.rb +146 -6
- data/lib/phlex/reactive/streamable.rb +158 -12
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +57 -5
- metadata +3 -2
|
@@ -5,8 +5,10 @@ module Phlex
|
|
|
5
5
|
# Component turns a self-contained Phlex component into a Livewire-style
|
|
6
6
|
# reactive unit: declare actions in Ruby, and the generic `reactive`
|
|
7
7
|
# Stimulus controller wires clicks/inputs to an HTTP round trip that
|
|
8
|
-
# re-renders the component and
|
|
9
|
-
#
|
|
8
|
+
# re-renders the component and applies it back into the DOM (a plain replace
|
|
9
|
+
# by default; return Response.morph(self) to morph in place and keep the
|
|
10
|
+
# focused input — issue #28). No per-feature Stimulus controllers, no
|
|
11
|
+
# hand-picked Turbo targets.
|
|
10
12
|
#
|
|
11
13
|
# Include alongside Phlex::Reactive::Streamable (which provides #id and the
|
|
12
14
|
# re-render machinery).
|
|
@@ -60,12 +62,31 @@ module Phlex
|
|
|
60
62
|
# A declared, client-invokable action and its param schema.
|
|
61
63
|
Action = Data.define(:name, :params)
|
|
62
64
|
|
|
65
|
+
# A declared add/remove-row collection (issue #35): the list contract tied
|
|
66
|
+
# into one unit — the per-row item component, the container DOM id rows
|
|
67
|
+
# live in, an optional companion count id, an optional empty-state
|
|
68
|
+
# component, and a size resolver (a proc evaluated against the container
|
|
69
|
+
# instance) used to re-render the count and toggle the empty-state at the
|
|
70
|
+
# 0<->1 boundary. count/empty/size are nil when not declared, and the
|
|
71
|
+
# corresponding stream is simply omitted — so a list with just rows still
|
|
72
|
+
# works.
|
|
73
|
+
CollectionDefinition = Data.define(:name, :item, :container, :count, :empty, :size) do
|
|
74
|
+
# The collection's current size, evaluated against the bound container
|
|
75
|
+
# instance (instance_exec so the proc reads the component's ivars /
|
|
76
|
+
# association). nil when no resolver was declared — callers skip the
|
|
77
|
+
# count/empty streams that need a number.
|
|
78
|
+
def size_for(component)
|
|
79
|
+
size && component.instance_exec(&size)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
63
83
|
class_methods do
|
|
64
84
|
# Declare the ActiveRecord (GlobalID-able) record this component is
|
|
65
85
|
# rebuilt from. The signed token carries its GlobalID; the server
|
|
66
86
|
# re-finds it on each action. State lives in the DB.
|
|
67
87
|
def reactive_record(name)
|
|
68
88
|
@reactive_record_key = name.to_sym
|
|
89
|
+
remove_instance_variable(:@reactive_record_ivar) if defined?(@reactive_record_ivar)
|
|
69
90
|
end
|
|
70
91
|
|
|
71
92
|
def reactive_record_key
|
|
@@ -78,6 +99,7 @@ module Phlex
|
|
|
78
99
|
# reactive_state :count, :open
|
|
79
100
|
def reactive_state(*names)
|
|
80
101
|
reactive_state_keys.concat(names.map(&:to_sym))
|
|
102
|
+
@reactive_state_ivars = nil # rebuild the cached [key, ivar] pairs
|
|
81
103
|
end
|
|
82
104
|
|
|
83
105
|
def reactive_state_keys
|
|
@@ -118,6 +140,59 @@ module Phlex
|
|
|
118
140
|
reactive_actions.key?(name.to_sym)
|
|
119
141
|
end
|
|
120
142
|
|
|
143
|
+
# Declare an add/remove-row collection (issue #35) — the list contract
|
|
144
|
+
# in one place, so actions append/prepend/remove a row WITHOUT
|
|
145
|
+
# re-deriving the container id, count, and empty-state in every action:
|
|
146
|
+
#
|
|
147
|
+
# reactive_collection :items,
|
|
148
|
+
# item: ItemRowComponent, # the per-row Streamable component
|
|
149
|
+
# container: "items-list", # the DOM id rows live in
|
|
150
|
+
# count: "items-count", # optional companion id (the size badge)
|
|
151
|
+
# empty: ItemsEmptyComponent, # optional empty-state component
|
|
152
|
+
# size: -> { @record.items.size } # resolves the live size
|
|
153
|
+
#
|
|
154
|
+
# An action then governs the reply with one call:
|
|
155
|
+
# reply.append(:items, item) # row append + count + clear empty-state
|
|
156
|
+
# reply.remove(:items, id) # row remove + count + restore empty-state
|
|
157
|
+
#
|
|
158
|
+
# count/empty/size are optional: a list with just rows omits them and the
|
|
159
|
+
# corresponding stream isn't emitted. See Phlex::Reactive::Reply and the
|
|
160
|
+
# README "Reactive collections" section.
|
|
161
|
+
def reactive_collection(name, item:, container:, count: nil, empty: nil, size: nil)
|
|
162
|
+
reactive_collections[name.to_sym] =
|
|
163
|
+
CollectionDefinition.new(name: name.to_sym, item:, container:, count:, empty:, size:)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def reactive_collections
|
|
167
|
+
@reactive_collections ||= superclass.respond_to?(:reactive_collections) ? superclass.reactive_collections.dup : {}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def reactive_collection_def(name)
|
|
171
|
+
reactive_collections[name.to_sym]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def reactive_collection?(name)
|
|
175
|
+
reactive_collections.key?(name.to_sym)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The record's instance-variable symbol (e.g. :@todo), computed once.
|
|
179
|
+
# reactive_token reads it on every render; interpolating :"@#{key}" each
|
|
180
|
+
# time would allocate a symbol per render. Nil when record-less. Memoized
|
|
181
|
+
# per class; reset alongside reactive_record so it can't go stale.
|
|
182
|
+
def reactive_record_ivar
|
|
183
|
+
return @reactive_record_ivar if defined?(@reactive_record_ivar)
|
|
184
|
+
|
|
185
|
+
@reactive_record_ivar = reactive_record_key ? :"@#{reactive_record_key}" : nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# [string_key, ivar_symbol] pairs for the signed state, computed once.
|
|
189
|
+
# reactive_token walks these every render; precomputing the "count"/:@count
|
|
190
|
+
# forms avoids a String + Symbol allocation per key per render. Memoized
|
|
191
|
+
# per class; reset when reactive_state adds a key.
|
|
192
|
+
def reactive_state_ivars
|
|
193
|
+
@reactive_state_ivars ||= reactive_state_keys.map { [it.to_s, :"@#{it}"] }
|
|
194
|
+
end
|
|
195
|
+
|
|
121
196
|
# Rebuild a component instance from a verified identity payload. Called
|
|
122
197
|
# by the action endpoint after the token signature is verified.
|
|
123
198
|
#
|
|
@@ -137,14 +212,14 @@ module Phlex
|
|
|
137
212
|
|
|
138
213
|
if reactive_state_keys.any?
|
|
139
214
|
state = payload.fetch("s", {})
|
|
140
|
-
reactive_state_keys.each do
|
|
215
|
+
reactive_state_keys.each do
|
|
141
216
|
# Use key presence, not the value: a signed `nil` (nullable state)
|
|
142
217
|
# must round-trip distinctly. Only a genuinely absent key falls
|
|
143
218
|
# back to the component's initialize default; `false` and `nil`
|
|
144
219
|
# both survive.
|
|
145
|
-
next unless state.key?(
|
|
220
|
+
next unless state.key?(it.to_s)
|
|
146
221
|
|
|
147
|
-
kwargs[
|
|
222
|
+
kwargs[it] = state[it.to_s]
|
|
148
223
|
end
|
|
149
224
|
end
|
|
150
225
|
|
|
@@ -167,6 +242,22 @@ module Phlex
|
|
|
167
242
|
Phlex::Reactive.current_connection_id
|
|
168
243
|
end
|
|
169
244
|
|
|
245
|
+
# Subject-bound reply builder — the preferred way to control an action's
|
|
246
|
+
# reply. `reply.replace.flash(:error, msg)` reads cleaner than
|
|
247
|
+
# `Phlex::Reactive::Response.replace(self).flash(:error, msg)`: the
|
|
248
|
+
# component is the implicit subject (no `self` to thread) and there's no
|
|
249
|
+
# constant to qualify (reply is a method, so a namespaced component needs
|
|
250
|
+
# no `Response = …` alias). It returns the same immutable Response the
|
|
251
|
+
# endpoint reads, so chaining and the legacy return-value contract are
|
|
252
|
+
# unchanged. See Phlex::Reactive::Reply.
|
|
253
|
+
#
|
|
254
|
+
# def archive = reply.remove
|
|
255
|
+
# def go_home = reply.redirect("/todos")
|
|
256
|
+
# def update(name:) = (@account.update!(name:); reply.morph)
|
|
257
|
+
def reply
|
|
258
|
+
Phlex::Reactive::Reply.new(self)
|
|
259
|
+
end
|
|
260
|
+
|
|
170
261
|
# Root-element attributes: marks the element reactive and carries the
|
|
171
262
|
# signed identity token. Spread onto the root:
|
|
172
263
|
# div(id:, **reactive_attrs) { ... }
|
|
@@ -193,12 +284,18 @@ module Phlex
|
|
|
193
284
|
# live-update-as-you-type doesn't POST per keystroke. A blur flushes a
|
|
194
285
|
# pending dispatch so the last edit is never dropped. Omit it for the
|
|
195
286
|
# immediate-dispatch default.
|
|
287
|
+
# The verbatim JSON for an empty explicit-params payload. The common
|
|
288
|
+
# trigger (on(:increment), no params) hits this on EVERY render — skipping
|
|
289
|
+
# params.to_json (which re-serializes {} to the same "{}" each time) avoids
|
|
290
|
+
# a per-render allocation while keeping the wire format byte-identical.
|
|
291
|
+
EMPTY_PARAMS_JSON = "{}"
|
|
292
|
+
|
|
196
293
|
def on(action_name, event: "click", debounce: nil, **params)
|
|
197
294
|
attrs = {
|
|
198
295
|
data: {
|
|
199
296
|
action: "#{event}->reactive#dispatch",
|
|
200
297
|
reactive_action_param: action_name.to_s,
|
|
201
|
-
reactive_params_param: params.to_json
|
|
298
|
+
reactive_params_param: params.empty? ? EMPTY_PARAMS_JSON : params.to_json
|
|
202
299
|
}
|
|
203
300
|
}
|
|
204
301
|
attrs[:data][:reactive_debounce_param] = debounce if debounce
|
|
@@ -216,7 +313,7 @@ module Phlex
|
|
|
216
313
|
# hatch). The trigger (on(:save)) stays on the button, not the field — so
|
|
217
314
|
# focusing the input doesn't dispatch and collapse edit mode.
|
|
218
315
|
def reactive_field(param, **attrs)
|
|
219
|
-
{name: param.to_s, **attrs}
|
|
316
|
+
{ name: param.to_s, **attrs }
|
|
220
317
|
end
|
|
221
318
|
|
|
222
319
|
# Render an <input> already bound to an action param (issue #23). Sugar for
|
|
@@ -230,8 +327,8 @@ module Phlex
|
|
|
230
327
|
# is the element's content, so the awkward FormBuilder positional split
|
|
231
328
|
# (where name: lands after the options/html-options args) goes away:
|
|
232
329
|
# reactive_select(:status) { @statuses.each { |s| option(value: s, selected: s == @record.status) { s } } }
|
|
233
|
-
def reactive_select(param, **attrs, &
|
|
234
|
-
select(**reactive_field(param, **attrs), &
|
|
330
|
+
def reactive_select(param, **attrs, &)
|
|
331
|
+
select(**reactive_field(param, **attrs), &)
|
|
235
332
|
end
|
|
236
333
|
|
|
237
334
|
# Map a declared nested param onto Rails' <assoc>_attributes, carrying the
|
|
@@ -247,7 +344,7 @@ module Phlex
|
|
|
247
344
|
existing = reactive_record_for_nested.public_send(association)
|
|
248
345
|
merged[:id] = existing.id if existing
|
|
249
346
|
|
|
250
|
-
{"#{association}_attributes": merged}
|
|
347
|
+
{ "#{association}_attributes": merged }
|
|
251
348
|
end
|
|
252
349
|
|
|
253
350
|
# Map a nested param onto <assoc>_attributes (with id preservation) AND
|
|
@@ -265,9 +362,7 @@ module Phlex
|
|
|
265
362
|
# record-backed component).
|
|
266
363
|
def reactive_record_for_nested
|
|
267
364
|
key = self.class.reactive_record_key
|
|
268
|
-
unless key
|
|
269
|
-
raise Error, "#{self.class} must declare `reactive_record` to use nested_update!/nested_attributes"
|
|
270
|
-
end
|
|
365
|
+
raise Error, "#{self.class} must declare `reactive_record` to use nested_update!/nested_attributes" unless key
|
|
271
366
|
|
|
272
367
|
instance_variable_get(:"@#{key}")
|
|
273
368
|
end
|
|
@@ -279,17 +374,18 @@ module Phlex
|
|
|
279
374
|
# record. Record-only ({c, gid}) and state-only ({c, s}) shapes are
|
|
280
375
|
# unchanged.
|
|
281
376
|
def reactive_token
|
|
282
|
-
|
|
377
|
+
klass = self.class
|
|
378
|
+
payload = { "c" => klass.name }
|
|
283
379
|
|
|
284
|
-
if
|
|
285
|
-
|
|
286
|
-
payload["gid"] = record.to_gid.to_s
|
|
380
|
+
if (record_ivar = klass.reactive_record_ivar)
|
|
381
|
+
payload["gid"] = instance_variable_get(record_ivar).to_gid.to_s
|
|
287
382
|
end
|
|
288
383
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
384
|
+
state_ivars = klass.reactive_state_ivars
|
|
385
|
+
unless state_ivars.empty?
|
|
386
|
+
state = {}
|
|
387
|
+
state_ivars.each { |key, ivar| state[key] = instance_variable_get(ivar).as_json }
|
|
388
|
+
payload["s"] = state
|
|
293
389
|
end
|
|
294
390
|
|
|
295
391
|
Phlex::Reactive.sign(payload)
|
|
@@ -13,27 +13,27 @@ module Phlex
|
|
|
13
13
|
|
|
14
14
|
# Mount POST /reactive/actions -> Phlex::Reactive::ActionsController#create.
|
|
15
15
|
# Apps can change the path with Phlex::Reactive.action_path before boot.
|
|
16
|
-
initializer "phlex_reactive.routes" do
|
|
17
|
-
|
|
16
|
+
initializer "phlex_reactive.routes" do
|
|
17
|
+
it.routes.append do
|
|
18
18
|
post Phlex::Reactive.action_path, to: "phlex/reactive/actions#create", as: :phlex_reactive_action
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
# Make reactive_controller.js available to Propshaft/Sprockets so it can
|
|
23
23
|
# be included via the asset pipeline or pinned in importmap.
|
|
24
|
-
initializer "phlex_reactive.assets" do
|
|
25
|
-
if
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
initializer "phlex_reactive.assets" do
|
|
25
|
+
if it.config.respond_to?(:assets)
|
|
26
|
+
it.config.assets.paths << root.join("app/javascript").to_s
|
|
27
|
+
it.config.assets.precompile += %w[phlex/reactive/reactive_controller.js]
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
# Auto-pin the client controller for importmap apps so it loads without
|
|
32
32
|
# manual configuration. Apps that don't use importmap include it via the
|
|
33
33
|
# asset pipeline instead (see README).
|
|
34
|
-
initializer "phlex_reactive.importmap", after: "importmap" do
|
|
35
|
-
if defined?(::Importmap::Map) &&
|
|
36
|
-
|
|
34
|
+
initializer "phlex_reactive.importmap", after: "importmap" do
|
|
35
|
+
if defined?(::Importmap::Map) && it.respond_to?(:importmap)
|
|
36
|
+
it.importmap.pin(
|
|
37
37
|
"phlex/reactive/reactive_controller",
|
|
38
38
|
to: "phlex/reactive/reactive_controller.js",
|
|
39
39
|
preload: true
|
|
@@ -41,6 +41,15 @@ module Phlex
|
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
# Flush memoized off-request view contexts on every code reload (dev) so a
|
|
45
|
+
# reloaded renderer controller class is never served from a stale memo. In
|
|
46
|
+
# production to_prepare runs once (eager load), so the cache simply builds
|
|
47
|
+
# fresh after boot. See Streamable.reset_all_view_contexts!.
|
|
48
|
+
config.to_prepare do
|
|
49
|
+
Phlex::Reactive::Streamable.reset_all_view_contexts!
|
|
50
|
+
Phlex::Reactive.reset_flash_builder!
|
|
51
|
+
end
|
|
52
|
+
|
|
44
53
|
# Boot-time guard (issue #26): warn if the action path doesn't resolve to
|
|
45
54
|
# the gem controller. Runs after_initialize so the host's full route set
|
|
46
55
|
# (including a bottom-of-file catch-all that would shadow our appended
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# A component-bound facade over Response — the surface an action body uses to
|
|
6
|
+
# control its reply. Component#reply returns one, so an action writes
|
|
7
|
+
#
|
|
8
|
+
# reply.replace.flash(:error, msg)
|
|
9
|
+
#
|
|
10
|
+
# instead of
|
|
11
|
+
#
|
|
12
|
+
# Phlex::Reactive::Response.replace(self).flash(:error, msg)
|
|
13
|
+
#
|
|
14
|
+
# Two warts disappear: there is no constant to qualify (reply is a method,
|
|
15
|
+
# resolved on the component — so a namespaced component needs no
|
|
16
|
+
# `Response = …` alias) and no `self` to thread (the component is bound).
|
|
17
|
+
#
|
|
18
|
+
# Reply is NOT a Response and does NOT subclass one: each verb forwards to
|
|
19
|
+
# the Response class method, supplying the bound component as the subject, and
|
|
20
|
+
# returns the real, frozen Response value the endpoint already honors. So
|
|
21
|
+
# immutability, chaining (.flash/.stream/.also_update/.also_replace), and
|
|
22
|
+
# render_self? are inherited untouched — and there is no "verb after a chained
|
|
23
|
+
# Response" asymmetry, because the chain is plain Response all the way down.
|
|
24
|
+
#
|
|
25
|
+
# Response remains the public value object (it's what the endpoint reads); it
|
|
26
|
+
# is simply an internal detail you rarely name directly now.
|
|
27
|
+
class Reply
|
|
28
|
+
# Distinguishes `reply.remove` (no args — remove the bound component
|
|
29
|
+
# itself) from `reply.remove(:items, model)` (remove a collection row).
|
|
30
|
+
# A sentinel, not nil, so `reply.remove(:items, nil)` stays unambiguous.
|
|
31
|
+
UNSET = Object.new
|
|
32
|
+
private_constant :UNSET
|
|
33
|
+
|
|
34
|
+
def initialize(component)
|
|
35
|
+
@component = component
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Self-targeting builders — the bound component is supplied implicitly.
|
|
39
|
+
|
|
40
|
+
# Re-render in place. `morph: true` morphs the subtree (preserves the
|
|
41
|
+
# focused input + caret) instead of an outerHTML swap — see #morph.
|
|
42
|
+
def replace(morph: false)
|
|
43
|
+
Response.replace(@component, morph:)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Re-render in place via Idiomorph (method="morph") — keeps focus + caret.
|
|
47
|
+
def morph
|
|
48
|
+
Response.morph(@component)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Morph only inner HTML (preserves the root element + its token attr).
|
|
52
|
+
def update
|
|
53
|
+
Response.update(@component)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Reactive collections (issue #35) — add/remove a row in a declared
|
|
57
|
+
# reactive_collection, emitting the row stream + the count companion +
|
|
58
|
+
# the empty-state toggle as ONE Response. The bound component is the
|
|
59
|
+
# container (it carries the declaration + size resolver).
|
|
60
|
+
#
|
|
61
|
+
# def add_item(...) = (item = @list.items.create!(...); reply.append(:items, item))
|
|
62
|
+
# def remove_item(id:) = (@list.items.find(id).destroy!; reply.remove(:items, id))
|
|
63
|
+
#
|
|
64
|
+
# `model` is the row's record; remove also accepts the row's dom-id string.
|
|
65
|
+
def append(name, model)
|
|
66
|
+
Response.collection_append(@component, name, model)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def prepend(name, model)
|
|
70
|
+
Response.collection_prepend(@component, name, model)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Two forms:
|
|
74
|
+
# reply.remove # remove the bound component's own element
|
|
75
|
+
# reply.remove(:items, model) # remove a collection row + count + empty
|
|
76
|
+
def remove(name = UNSET, model = UNSET)
|
|
77
|
+
return Response.remove(@component) if name.equal?(UNSET)
|
|
78
|
+
|
|
79
|
+
Response.collection_remove(@component, name, model)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Subject-free builders — pass straight through so they read naturally.
|
|
83
|
+
|
|
84
|
+
# Client-side full navigation (Turbo.visit). Pass a *_url.
|
|
85
|
+
def redirect(url)
|
|
86
|
+
Response.redirect(url)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Escape hatch / multi-stream root: zero or more raw turbo-stream strings.
|
|
90
|
+
def with(*strings)
|
|
91
|
+
Response.with(*strings)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Self-targeting again: emit exactly these streams with a TOKEN-ONLY refresh
|
|
95
|
+
# (issue #30) — partial/per-field update, NO full-self replace, so the
|
|
96
|
+
# component's live inputs survive. The bound component supplies the token.
|
|
97
|
+
def streams(*strings)
|
|
98
|
+
Response.streams(@component, *strings)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -17,11 +17,23 @@ module Phlex
|
|
|
17
17
|
# Response.redirect(article_url(@article)) # slug changed -> Turbo.visit the new URL
|
|
18
18
|
# Response.replace(self).stream(Totals.update(@order)) # multi-stream
|
|
19
19
|
class Response
|
|
20
|
-
attr_reader :streams, :redirect_url
|
|
20
|
+
attr_reader :streams, :redirect_url, :token_component
|
|
21
21
|
|
|
22
22
|
class << self
|
|
23
23
|
# Re-render the component in place (explicit form of today's default).
|
|
24
|
-
|
|
24
|
+
# `morph: true` morphs the subtree (preserves the focused input + caret)
|
|
25
|
+
# instead of an outerHTML swap — see .morph (issue #28).
|
|
26
|
+
def replace(component, morph: false)
|
|
27
|
+
new(streams: [morph ? component.to_stream_morph : component.to_stream_replace])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Re-render the component in place via Idiomorph (issue #28). Emits
|
|
31
|
+
# `<turbo-stream action="replace" method="morph">`, so Turbo 8 morphs the
|
|
32
|
+
# subtree — the focused <input> + caret survive the save. Use this for
|
|
33
|
+
# per-field reactive editing (a "spreadsheet" grid where a debounced save
|
|
34
|
+
# fires while the user is still typing/tabbing). The morphed root still
|
|
35
|
+
# carries the fresh signed token, so the next action verifies.
|
|
36
|
+
def morph(component) = new(streams: [component.to_stream_morph])
|
|
25
37
|
|
|
26
38
|
# Morph only inner HTML (preserves the root element + its token attr).
|
|
27
39
|
def update(component) = new(streams: [component.to_stream_update])
|
|
@@ -39,6 +51,120 @@ module Phlex
|
|
|
39
51
|
# Escape hatch / multi-stream root: zero or more raw turbo-stream strings.
|
|
40
52
|
def with(*strings) = new(streams: strings.flatten)
|
|
41
53
|
|
|
54
|
+
# --- Reactive collections (issue #35) ---
|
|
55
|
+
# Add/remove a row in a declared reactive_collection, emitting the row
|
|
56
|
+
# stream + the count companion update + the empty-state toggle as ONE
|
|
57
|
+
# Response. `component` is the bound CONTAINER (it carries the
|
|
58
|
+
# declaration and the size resolver); `model` is the row's record (or, for
|
|
59
|
+
# remove, a model OR a dom-id string). count/empty/size are optional — a
|
|
60
|
+
# stream is emitted only for the pieces declared.
|
|
61
|
+
#
|
|
62
|
+
# render_self is false: the row append/prepend/remove IS the update, so
|
|
63
|
+
# we must NOT also replace the whole container (that would re-render every
|
|
64
|
+
# row and clobber the just-streamed delta).
|
|
65
|
+
#
|
|
66
|
+
# token_component is the CONTAINER (cosmos#1939): a reply that does NOT
|
|
67
|
+
# re-render self must STILL refresh self's signed token, or the list is
|
|
68
|
+
# add-once-only — correct on the first click, then every subsequent dispatch
|
|
69
|
+
# from the list root is rejected (its token went stale) with no error. The
|
|
70
|
+
# container owns the add/remove trigger, so the endpoint appends its inert
|
|
71
|
+
# `reactive:token` stream (the same #30 machinery reply.streams uses) to roll
|
|
72
|
+
# the token forward without re-rendering the rows.
|
|
73
|
+
def collection_append(component, name, model)
|
|
74
|
+
definition = collection_def!(component, name)
|
|
75
|
+
new(streams: collection_add_streams(definition, component, model, :append),
|
|
76
|
+
render_self: false, token_component: component)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def collection_prepend(component, name, model)
|
|
80
|
+
definition = collection_def!(component, name)
|
|
81
|
+
new(streams: collection_add_streams(definition, component, model, :prepend),
|
|
82
|
+
render_self: false, token_component: component)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def collection_remove(component, name, model)
|
|
86
|
+
definition = collection_def!(component, name)
|
|
87
|
+
new(streams: collection_remove_streams(definition, component, model),
|
|
88
|
+
render_self: false, token_component: component)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Resolve the declaration off the container's class, raising a clear error
|
|
94
|
+
# for an undeclared name (a typo'd collection should fail loudly, not
|
|
95
|
+
# silently emit an empty Response).
|
|
96
|
+
def collection_def!(component, name)
|
|
97
|
+
component.class.reactive_collection_def(name) ||
|
|
98
|
+
raise(Phlex::Reactive::Error, "undeclared reactive_collection :#{name} on #{component.class}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Row add (append/prepend) + count + empty-state clear. The empty-state is
|
|
102
|
+
# removed only when the list just crossed 0->1 (size == 1) — appending to
|
|
103
|
+
# an already-populated list leaves it untouched.
|
|
104
|
+
def collection_add_streams(definition, component, model, action)
|
|
105
|
+
streams = [definition.item.public_send(action, target: definition.container, model:)]
|
|
106
|
+
append_count_stream(streams, definition, component)
|
|
107
|
+
|
|
108
|
+
size = definition.size_for(component)
|
|
109
|
+
streams << definition.empty.new.to_stream_remove if definition.empty && size == 1
|
|
110
|
+
streams
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Row remove + count + empty-state restore. The empty-state is appended
|
|
114
|
+
# back into the container only when the list just emptied (size == 0).
|
|
115
|
+
def collection_remove_streams(definition, component, model)
|
|
116
|
+
streams = [collection_row_remove(definition, model)]
|
|
117
|
+
append_count_stream(streams, definition, component)
|
|
118
|
+
|
|
119
|
+
size = definition.size_for(component)
|
|
120
|
+
if definition.empty && size&.zero?
|
|
121
|
+
# Render the empty-state and append it INTO the container (not its
|
|
122
|
+
# own id) — restoring "No items yet" when the last row was removed.
|
|
123
|
+
# model: nil builds it argument-free (an empty-state is a static view).
|
|
124
|
+
streams << definition.empty.append(target: definition.container, model: nil)
|
|
125
|
+
end
|
|
126
|
+
streams
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Remove the row by its DOM id. Accepts the record (so dom_id is derived)
|
|
130
|
+
# or an already-built dom-id string (e.g. the value the row used as #id).
|
|
131
|
+
def collection_row_remove(definition, model)
|
|
132
|
+
if model.is_a?(String)
|
|
133
|
+
Phlex::Reactive.flash_builder.remove(model)
|
|
134
|
+
else
|
|
135
|
+
definition.item.remove(model)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Append the count companion's update stream when a count id + a size
|
|
140
|
+
# resolver are both declared. The size is a number, HTML-escaped by Turbo.
|
|
141
|
+
def append_count_stream(streams, definition, component)
|
|
142
|
+
return unless definition.count
|
|
143
|
+
|
|
144
|
+
size = definition.size_for(component)
|
|
145
|
+
return if size.nil?
|
|
146
|
+
|
|
147
|
+
streams << update_stream(definition.count, size.to_s)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
public
|
|
151
|
+
|
|
152
|
+
# Partial / per-field update with a TOKEN-ONLY refresh (issue #30). Emits
|
|
153
|
+
# EXACTLY the given streams — no forced full-self replace — but binds
|
|
154
|
+
# `component` so the endpoint appends its tiny `to_stream_token` stream.
|
|
155
|
+
# So the signed token rolls forward (the next action verifies) while the
|
|
156
|
+
# component's own live inputs are never torn down: ideal for a
|
|
157
|
+
# spreadsheet-like grid where a debounced save re-streams only a total
|
|
158
|
+
# cell and the user is still typing in a sibling field.
|
|
159
|
+
#
|
|
160
|
+
# Response.streams(self, Totals.update(@invoice)) # update only the totals
|
|
161
|
+
#
|
|
162
|
+
# render_self is false (we do NOT inject the full replace); the token is
|
|
163
|
+
# refreshed by the bound component's token stream instead.
|
|
164
|
+
def streams(component, *strings)
|
|
165
|
+
new(streams: strings.flatten, render_self: false, token_component: component)
|
|
166
|
+
end
|
|
167
|
+
|
|
42
168
|
# Build a flash turbo-stream that appends `content` into a host-app
|
|
43
169
|
# container. `content` is a Phlex component instance (rendered through
|
|
44
170
|
# the configured renderer so t()/url_for work) or a ready HTML string —
|
|
@@ -73,10 +199,16 @@ module Phlex
|
|
|
73
199
|
# GUARANTEES the component's own replace is present so its
|
|
74
200
|
# data-reactive-token-value refreshes (the client extracts the next token
|
|
75
201
|
# from the response HTML). remove/redirect set it false (nothing stays).
|
|
76
|
-
|
|
202
|
+
#
|
|
203
|
+
# token_component: set by .streams (issue #30) — a partial update that opts
|
|
204
|
+
# OUT of the full-self replace but still needs the token refreshed. The
|
|
205
|
+
# endpoint appends this component's tiny to_stream_token instead, so the
|
|
206
|
+
# token rolls forward without re-rendering (and clobbering) the children.
|
|
207
|
+
def initialize(streams: [], redirect_url: nil, render_self: true, token_component: nil)
|
|
77
208
|
@streams = streams.freeze
|
|
78
209
|
@redirect_url = redirect_url
|
|
79
210
|
@render_self = render_self
|
|
211
|
+
@token_component = token_component
|
|
80
212
|
freeze
|
|
81
213
|
end
|
|
82
214
|
|
|
@@ -86,7 +218,8 @@ module Phlex
|
|
|
86
218
|
self.class.new(
|
|
87
219
|
streams: @streams + more.flatten,
|
|
88
220
|
redirect_url: @redirect_url,
|
|
89
|
-
render_self: @render_self
|
|
221
|
+
render_self: @render_self,
|
|
222
|
+
token_component: @token_component
|
|
90
223
|
)
|
|
91
224
|
end
|
|
92
225
|
|
|
@@ -113,12 +246,19 @@ module Phlex
|
|
|
113
246
|
# Like #also_update, but renders ANOTHER Streamable component and replaces
|
|
114
247
|
# it by its own #id — for a companion that is itself a component.
|
|
115
248
|
# Response.replace(self).also_replace(SummaryCard.new(order: @order))
|
|
116
|
-
|
|
117
|
-
|
|
249
|
+
# `morph: true` morphs the companion in place (issue #28) — use it when the
|
|
250
|
+
# companion also holds focusable inputs that must survive the re-render.
|
|
251
|
+
def also_replace(component, morph: false)
|
|
252
|
+
stream(morph ? component.to_stream_morph : component.to_stream_replace)
|
|
118
253
|
end
|
|
119
254
|
|
|
120
255
|
def redirect? = !@redirect_url.nil?
|
|
121
256
|
def render_self? = @render_self
|
|
257
|
+
|
|
258
|
+
# True when a partial update (.streams) opted out of the full-self replace
|
|
259
|
+
# but still needs the token rolled forward — the endpoint appends the bound
|
|
260
|
+
# component's tiny token-only stream (issue #30).
|
|
261
|
+
def refresh_token? = !@token_component.nil?
|
|
122
262
|
end
|
|
123
263
|
end
|
|
124
264
|
end
|