phlex-reactive 0.3.0 → 0.4.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 +200 -0
- data/README.md +111 -3
- data/app/controllers/phlex/reactive/actions_controller.rb +49 -12
- data/app/javascript/phlex/reactive/reactive_controller.js +113 -17
- data/lib/generators/phlex/reactive/install/install_generator.rb +6 -6
- data/lib/phlex/reactive/component.rb +97 -19
- data/lib/phlex/reactive/engine.rb +18 -9
- data/lib/phlex/reactive/reply.rb +30 -3
- data/lib/phlex/reactive/response.rb +98 -0
- data/lib/phlex/reactive/streamable.rb +110 -11
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +81 -5
- metadata +2 -2
|
@@ -8,9 +8,12 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
8
8
|
// (replace by default; method="morph" — Response.morph — preserves focus).
|
|
9
9
|
//
|
|
10
10
|
// Wire format (client -> server), POST <action path>, turbo-stream Accept:
|
|
11
|
-
// { token: "<signed identity>", act: "<action>", params: {...} }
|
|
11
|
+
// { token: "<signed identity>", act: "<action>", params: {...} } (JSON)
|
|
12
12
|
// (`act`, not `action`: `action` is a reserved Rails routing param.)
|
|
13
13
|
// The token is a MessageVerifier-signed { component, gid } — NO state is sent.
|
|
14
|
+
// When the root holds a chosen <input type="file">, the SAME payload is sent as
|
|
15
|
+
// multipart FormData instead (token/act flat, params bracketed, files appended)
|
|
16
|
+
// so an upload reaches the action (issue #34) — only the encoding differs.
|
|
14
17
|
// The response is a <turbo-stream> that replaces the component by its id.
|
|
15
18
|
//
|
|
16
19
|
// Server -> client live updates use the SAME element id, pushed over the
|
|
@@ -119,6 +122,7 @@ export default class extends Controller {
|
|
|
119
122
|
|
|
120
123
|
#tokenCache // freshest token, threaded synchronously across queued requests
|
|
121
124
|
#debounceTimers = new Map() // trigger element -> { timer, flush } pending dispatch
|
|
125
|
+
#actionPathCache // page-stable action path, resolved once per controller
|
|
122
126
|
|
|
123
127
|
// Mark that a reactive controller actually connected, so the registration
|
|
124
128
|
// guard above knows the controller was registered (issue #26 part 2).
|
|
@@ -202,24 +206,34 @@ export default class extends Controller {
|
|
|
202
206
|
|
|
203
207
|
async #perform(action, params) {
|
|
204
208
|
// Auto-collect named field values inside this component so a button-
|
|
205
|
-
// triggered action still receives sibling inputs (Livewire-style)
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
209
|
+
// triggered action still receives sibling inputs (Livewire-style), plus any
|
|
210
|
+
// chosen file inputs in the SAME walk. Explicit params
|
|
211
|
+
// (data-reactive-params-param) win over collected fields.
|
|
212
|
+
const { fields, files } = this.#collectFields()
|
|
213
|
+
const allParams = { ...fields, ...this.#parseParams(params) }
|
|
214
|
+
const token = this.#currentToken
|
|
215
|
+
|
|
216
|
+
// File/multipart path (issue #34): if THIS root has a populated
|
|
217
|
+
// <input type="file">, the action can't be JSON (JSON.stringify drops the
|
|
218
|
+
// File). Send FormData instead — token + act + scalar params as fields, each
|
|
219
|
+
// chosen file appended. The morph/token machinery downstream is identical;
|
|
220
|
+
// only the request ENCODING differs when files are present.
|
|
221
|
+
const multipart = files.length > 0
|
|
222
|
+
const body = multipart
|
|
223
|
+
? this.#buildFormData(token, action, allParams, files)
|
|
224
|
+
: JSON.stringify({ token, act: action, params: allParams })
|
|
214
225
|
|
|
215
226
|
this.element.setAttribute("aria-busy", "true")
|
|
216
227
|
|
|
217
228
|
try {
|
|
218
229
|
const headers = {
|
|
219
|
-
"Content-Type": "application/json",
|
|
220
230
|
Accept: "text/vnd.turbo-stream.html",
|
|
221
231
|
"X-CSRF-Token": this.#csrfToken(),
|
|
222
232
|
}
|
|
233
|
+
// For JSON we declare the content type; for multipart we must NOT — the
|
|
234
|
+
// browser sets `multipart/form-data; boundary=…` itself, and overriding it
|
|
235
|
+
// would strip the boundary and corrupt the body server-side.
|
|
236
|
+
if (!multipart) headers["Content-Type"] = "application/json"
|
|
223
237
|
// Send the pgbus SSE connection id (if subscribed) so the server can
|
|
224
238
|
// exclude this connection from its own broadcast echo — the actor
|
|
225
239
|
// already gets the action's HTTP response. Harmless without pgbus.
|
|
@@ -287,12 +301,22 @@ export default class extends Controller {
|
|
|
287
301
|
return el.closest('[data-controller~="reactive"]') === this.element
|
|
288
302
|
}
|
|
289
303
|
|
|
304
|
+
// One walk over THIS root's named controls (not a nested reactive root's),
|
|
305
|
+
// returning both the scalar `fields` and any chosen `files`. A file input's
|
|
306
|
+
// `.value` is the useless "C:\fakepath\…" string, never a scalar — so its
|
|
307
|
+
// chosen files are collected separately (honoring `multiple`) and it adds no
|
|
308
|
+
// phantom blank value (issue #34). An empty `files` keeps the JSON path.
|
|
290
309
|
#collectFields() {
|
|
291
310
|
const fields = {}
|
|
292
|
-
|
|
311
|
+
const files = []
|
|
293
312
|
this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
|
|
294
313
|
if (!this.#ownsField(field)) return
|
|
295
|
-
if (field.type === "
|
|
314
|
+
if (field.type === "file") {
|
|
315
|
+
// Carry the input's `multiple` flag so #buildFormData keeps the array
|
|
316
|
+
// shape (params[name][]) even when the user picked exactly one file —
|
|
317
|
+
// otherwise a [:file] schema would see a lone scalar upload and drop it.
|
|
318
|
+
for (const file of field.files ?? []) files.push({ name: field.name, file, multiple: field.multiple })
|
|
319
|
+
} else if (field.type === "checkbox") {
|
|
296
320
|
fields[field.name] = field.checked
|
|
297
321
|
} else if (field.type === "radio") {
|
|
298
322
|
if (field.checked) fields[field.name] = field.value
|
|
@@ -320,7 +344,71 @@ export default class extends Controller {
|
|
|
320
344
|
fields[name] = el.value ?? el.textContent ?? el.innerHTML ?? ""
|
|
321
345
|
}
|
|
322
346
|
})
|
|
323
|
-
return fields
|
|
347
|
+
return { fields, files }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Build the multipart body (issue #34). `token`/`act` are flat fields the
|
|
351
|
+
// endpoint reads from params[:token]/params[:act]; scalar params nest under
|
|
352
|
+
// params[<key>] (Rails parses the bracket into params[:params]); each file is
|
|
353
|
+
// appended under params[<name>] (single) — a second file with the same name
|
|
354
|
+
// (a `multiple` picker, several inputs sharing a name) is sent as
|
|
355
|
+
// params[<name>][] so Rails coerces it to an array for a [:file] schema.
|
|
356
|
+
#buildFormData(token, action, params, files) {
|
|
357
|
+
const fd = new FormData()
|
|
358
|
+
fd.append("token", token)
|
|
359
|
+
fd.append("act", action)
|
|
360
|
+
for (const [key, value] of Object.entries(params)) {
|
|
361
|
+
this.#appendField(fd, `params[${key}]`, value)
|
|
362
|
+
}
|
|
363
|
+
const multiNames = this.#multiFileNames(files)
|
|
364
|
+
for (const { name, file, multiple } of files) {
|
|
365
|
+
// params[name][] when the input is `multiple` (array shape even for one
|
|
366
|
+
// file) OR the name repeats across inputs; otherwise a lone scalar file.
|
|
367
|
+
const asArray = multiple || multiNames.has(name)
|
|
368
|
+
const key = asArray ? `params[${name}][]` : `params[${name}]`
|
|
369
|
+
fd.append(key, file, file.name)
|
|
370
|
+
}
|
|
371
|
+
return fd
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Append a param leaf to FormData under its bracketed key. FormData carries
|
|
375
|
+
// only strings, so a NON-scalar param (a nested object or an array) is
|
|
376
|
+
// bracket-EXPANDED into params[key][sub] / params[key][index][...] fields —
|
|
377
|
+
// the SAME Rails-form shape the server's expand_bracket_keys / array_values
|
|
378
|
+
// already parse, so a JSON body and a multipart body coerce identically
|
|
379
|
+
// (issue #39). Previously a non-scalar was JSON.stringify'd into one
|
|
380
|
+
// params[key]='<json>' field, which the server received as an un-decodable
|
|
381
|
+
// String leaf and DROPPED (nested hash -> {}, array -> key removed).
|
|
382
|
+
//
|
|
383
|
+
// Arrays use NUMERIC indices (params[key][0], params[key][1]) — required for
|
|
384
|
+
// an array-of-hash so each element's sub-keys stay grouped and the server's
|
|
385
|
+
// index-hash sort rebuilds order; params[key][] would collapse them. A scalar
|
|
386
|
+
// (string/number/boolean) is one string field, mirroring the JSON wire shape
|
|
387
|
+
// (the server's :boolean/:integer casts read "true"/"42"). null/undefined is
|
|
388
|
+
// an empty field. An EMPTY array/object emits NOTHING — FormData can't carry
|
|
389
|
+
// []/{}, so the key is omitted and the action's keyword default applies (this
|
|
390
|
+
// differs from the JSON path, where an explicit [] coerces to an empty array).
|
|
391
|
+
#appendField(fd, key, value) {
|
|
392
|
+
if (value == null) {
|
|
393
|
+
fd.append(key, "")
|
|
394
|
+
} else if (Array.isArray(value)) {
|
|
395
|
+
value.forEach((element, index) => this.#appendField(fd, `${key}[${index}]`, element))
|
|
396
|
+
} else if (typeof value === "object") {
|
|
397
|
+
for (const [subKey, subValue] of Object.entries(value)) {
|
|
398
|
+
this.#appendField(fd, `${key}[${subKey}]`, subValue)
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
fd.append(key, String(value))
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Names that appear more than once across the chosen files (a `multiple`
|
|
406
|
+
// picker, or several file inputs sharing a name) — those go to params[name][]
|
|
407
|
+
// so the server sees an array; a lone file stays params[name].
|
|
408
|
+
#multiFileNames(files) {
|
|
409
|
+
const counts = new Map()
|
|
410
|
+
for (const { name } of files) counts.set(name, (counts.get(name) ?? 0) + 1)
|
|
411
|
+
return new Set([...counts].filter(([, n]) => n > 1).map(([name]) => name))
|
|
324
412
|
}
|
|
325
413
|
|
|
326
414
|
#parseParams(raw) {
|
|
@@ -332,13 +420,21 @@ export default class extends Controller {
|
|
|
332
420
|
}
|
|
333
421
|
}
|
|
334
422
|
|
|
423
|
+
// The action path comes from a <meta> tag that is fixed for the page's life,
|
|
424
|
+
// so resolve it once per controller and cache it — avoids a querySelector on
|
|
425
|
+
// every dispatch (this runs on the request hot path, once per click/keystroke
|
|
426
|
+
// round trip). Cached on the instance, so a fresh connect() (after a Turbo
|
|
427
|
+
// navigation swaps the element) re-reads it.
|
|
335
428
|
#actionPath() {
|
|
336
|
-
return (
|
|
429
|
+
return (this.#actionPathCache ??=
|
|
337
430
|
document.querySelector('meta[name="phlex-reactive-action-path"]')?.content ||
|
|
338
|
-
"/reactive/actions"
|
|
339
|
-
)
|
|
431
|
+
"/reactive/actions")
|
|
340
432
|
}
|
|
341
433
|
|
|
434
|
+
// CSRF token and connection id are read LIVE (not cached) on purpose: Rails
|
|
435
|
+
// can rotate the CSRF token, and the pgbus connection id changes on an SSE
|
|
436
|
+
// reconnect — caching either would send a stale value. A single querySelector
|
|
437
|
+
// per request is cheap next to the round trip itself.
|
|
342
438
|
#csrfToken() {
|
|
343
439
|
return document.querySelector('meta[name="csrf-token"]')?.content ?? ""
|
|
344
440
|
}
|
|
@@ -29,8 +29,8 @@ module Phlex
|
|
|
29
29
|
index = stimulus_index_path
|
|
30
30
|
|
|
31
31
|
unless index
|
|
32
|
-
say_status :skip, "no Stimulus controllers entrypoint found — register manually:\n" \
|
|
33
|
-
"
|
|
32
|
+
say_status :skip, "no Stimulus controllers entrypoint found — register manually:\n " \
|
|
33
|
+
"#{IMPORT_LINE}\n #{REGISTER_LINE}", :yellow
|
|
34
34
|
return
|
|
35
35
|
end
|
|
36
36
|
|
|
@@ -44,9 +44,9 @@ module Phlex
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
# Fallback: if the standard import line wasn't found, append both lines.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
return if File.read(index).include?(REGISTER_LINE)
|
|
48
|
+
|
|
49
|
+
append_to_file index, "\n#{IMPORT_LINE}\n#{REGISTER_LINE}\n"
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
def show_post_install
|
|
@@ -69,7 +69,7 @@ module Phlex
|
|
|
69
69
|
%w[
|
|
70
70
|
app/javascript/controllers/index.js
|
|
71
71
|
app/javascript/controllers/application.js
|
|
72
|
-
].map {
|
|
72
|
+
].map { File.join(destination_root, it) }.find { File.exist?(it) }
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
def relative(path)
|
|
@@ -62,12 +62,31 @@ module Phlex
|
|
|
62
62
|
# A declared, client-invokable action and its param schema.
|
|
63
63
|
Action = Data.define(:name, :params)
|
|
64
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
|
+
|
|
65
83
|
class_methods do
|
|
66
84
|
# Declare the ActiveRecord (GlobalID-able) record this component is
|
|
67
85
|
# rebuilt from. The signed token carries its GlobalID; the server
|
|
68
86
|
# re-finds it on each action. State lives in the DB.
|
|
69
87
|
def reactive_record(name)
|
|
70
88
|
@reactive_record_key = name.to_sym
|
|
89
|
+
remove_instance_variable(:@reactive_record_ivar) if defined?(@reactive_record_ivar)
|
|
71
90
|
end
|
|
72
91
|
|
|
73
92
|
def reactive_record_key
|
|
@@ -80,6 +99,7 @@ module Phlex
|
|
|
80
99
|
# reactive_state :count, :open
|
|
81
100
|
def reactive_state(*names)
|
|
82
101
|
reactive_state_keys.concat(names.map(&:to_sym))
|
|
102
|
+
@reactive_state_ivars = nil # rebuild the cached [key, ivar] pairs
|
|
83
103
|
end
|
|
84
104
|
|
|
85
105
|
def reactive_state_keys
|
|
@@ -120,6 +140,59 @@ module Phlex
|
|
|
120
140
|
reactive_actions.key?(name.to_sym)
|
|
121
141
|
end
|
|
122
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
|
+
|
|
123
196
|
# Rebuild a component instance from a verified identity payload. Called
|
|
124
197
|
# by the action endpoint after the token signature is verified.
|
|
125
198
|
#
|
|
@@ -139,14 +212,14 @@ module Phlex
|
|
|
139
212
|
|
|
140
213
|
if reactive_state_keys.any?
|
|
141
214
|
state = payload.fetch("s", {})
|
|
142
|
-
reactive_state_keys.each do
|
|
215
|
+
reactive_state_keys.each do
|
|
143
216
|
# Use key presence, not the value: a signed `nil` (nullable state)
|
|
144
217
|
# must round-trip distinctly. Only a genuinely absent key falls
|
|
145
218
|
# back to the component's initialize default; `false` and `nil`
|
|
146
219
|
# both survive.
|
|
147
|
-
next unless state.key?(
|
|
220
|
+
next unless state.key?(it.to_s)
|
|
148
221
|
|
|
149
|
-
kwargs[
|
|
222
|
+
kwargs[it] = state[it.to_s]
|
|
150
223
|
end
|
|
151
224
|
end
|
|
152
225
|
|
|
@@ -211,12 +284,18 @@ module Phlex
|
|
|
211
284
|
# live-update-as-you-type doesn't POST per keystroke. A blur flushes a
|
|
212
285
|
# pending dispatch so the last edit is never dropped. Omit it for the
|
|
213
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
|
+
|
|
214
293
|
def on(action_name, event: "click", debounce: nil, **params)
|
|
215
294
|
attrs = {
|
|
216
295
|
data: {
|
|
217
296
|
action: "#{event}->reactive#dispatch",
|
|
218
297
|
reactive_action_param: action_name.to_s,
|
|
219
|
-
reactive_params_param: params.to_json
|
|
298
|
+
reactive_params_param: params.empty? ? EMPTY_PARAMS_JSON : params.to_json
|
|
220
299
|
}
|
|
221
300
|
}
|
|
222
301
|
attrs[:data][:reactive_debounce_param] = debounce if debounce
|
|
@@ -234,7 +313,7 @@ module Phlex
|
|
|
234
313
|
# hatch). The trigger (on(:save)) stays on the button, not the field — so
|
|
235
314
|
# focusing the input doesn't dispatch and collapse edit mode.
|
|
236
315
|
def reactive_field(param, **attrs)
|
|
237
|
-
{name: param.to_s, **attrs}
|
|
316
|
+
{ name: param.to_s, **attrs }
|
|
238
317
|
end
|
|
239
318
|
|
|
240
319
|
# Render an <input> already bound to an action param (issue #23). Sugar for
|
|
@@ -248,8 +327,8 @@ module Phlex
|
|
|
248
327
|
# is the element's content, so the awkward FormBuilder positional split
|
|
249
328
|
# (where name: lands after the options/html-options args) goes away:
|
|
250
329
|
# reactive_select(:status) { @statuses.each { |s| option(value: s, selected: s == @record.status) { s } } }
|
|
251
|
-
def reactive_select(param, **attrs, &
|
|
252
|
-
select(**reactive_field(param, **attrs), &
|
|
330
|
+
def reactive_select(param, **attrs, &)
|
|
331
|
+
select(**reactive_field(param, **attrs), &)
|
|
253
332
|
end
|
|
254
333
|
|
|
255
334
|
# Map a declared nested param onto Rails' <assoc>_attributes, carrying the
|
|
@@ -265,7 +344,7 @@ module Phlex
|
|
|
265
344
|
existing = reactive_record_for_nested.public_send(association)
|
|
266
345
|
merged[:id] = existing.id if existing
|
|
267
346
|
|
|
268
|
-
{"#{association}_attributes": merged}
|
|
347
|
+
{ "#{association}_attributes": merged }
|
|
269
348
|
end
|
|
270
349
|
|
|
271
350
|
# Map a nested param onto <assoc>_attributes (with id preservation) AND
|
|
@@ -283,9 +362,7 @@ module Phlex
|
|
|
283
362
|
# record-backed component).
|
|
284
363
|
def reactive_record_for_nested
|
|
285
364
|
key = self.class.reactive_record_key
|
|
286
|
-
unless key
|
|
287
|
-
raise Error, "#{self.class} must declare `reactive_record` to use nested_update!/nested_attributes"
|
|
288
|
-
end
|
|
365
|
+
raise Error, "#{self.class} must declare `reactive_record` to use nested_update!/nested_attributes" unless key
|
|
289
366
|
|
|
290
367
|
instance_variable_get(:"@#{key}")
|
|
291
368
|
end
|
|
@@ -297,17 +374,18 @@ module Phlex
|
|
|
297
374
|
# record. Record-only ({c, gid}) and state-only ({c, s}) shapes are
|
|
298
375
|
# unchanged.
|
|
299
376
|
def reactive_token
|
|
300
|
-
|
|
377
|
+
klass = self.class
|
|
378
|
+
payload = { "c" => klass.name }
|
|
301
379
|
|
|
302
|
-
if
|
|
303
|
-
|
|
304
|
-
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
|
|
305
382
|
end
|
|
306
383
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
311
389
|
end
|
|
312
390
|
|
|
313
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
|
data/lib/phlex/reactive/reply.rb
CHANGED
|
@@ -25,6 +25,12 @@ module Phlex
|
|
|
25
25
|
# Response remains the public value object (it's what the endpoint reads); it
|
|
26
26
|
# is simply an internal detail you rarely name directly now.
|
|
27
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
|
+
|
|
28
34
|
def initialize(component)
|
|
29
35
|
@component = component
|
|
30
36
|
end
|
|
@@ -47,9 +53,30 @@ module Phlex
|
|
|
47
53
|
Response.update(@component)
|
|
48
54
|
end
|
|
49
55
|
|
|
50
|
-
#
|
|
51
|
-
|
|
52
|
-
|
|
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)
|
|
53
80
|
end
|
|
54
81
|
|
|
55
82
|
# Subject-free builders — pass straight through so they read naturally.
|
|
@@ -51,6 +51,104 @@ module Phlex
|
|
|
51
51
|
# Escape hatch / multi-stream root: zero or more raw turbo-stream strings.
|
|
52
52
|
def with(*strings) = new(streams: strings.flatten)
|
|
53
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
|
+
|
|
54
152
|
# Partial / per-field update with a TOKEN-ONLY refresh (issue #30). Emits
|
|
55
153
|
# EXACTLY the given streams — no forced full-self replace — but binds
|
|
56
154
|
# `component` so the endpoint appends its tiny `to_stream_token` stream.
|