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
|
@@ -78,6 +78,19 @@ module Phlex
|
|
|
78
78
|
return [redirect_stream(result.redirect_url)] if result.redirect?
|
|
79
79
|
|
|
80
80
|
streams = result.streams
|
|
81
|
+
|
|
82
|
+
# Partial update (Response.streams / reply.streams, issue #30): the action
|
|
83
|
+
# re-rendered only PART of the component and opted out of the full-self
|
|
84
|
+
# replace. Append a tiny token-only stream so the signed token still rolls
|
|
85
|
+
# forward WITHOUT re-rendering (and clobbering) the live inputs. Skip it
|
|
86
|
+
# only if the caller already supplied THIS component's token (idempotent) —
|
|
87
|
+
# the dedupe is scoped to the actor's own target, NOT a global substring,
|
|
88
|
+
# so a partial reply that legitimately includes another reactive
|
|
89
|
+
# component's stream (which carries its OWN token) still refreshes ours.
|
|
90
|
+
if result.refresh_token? && !carries_token_for?(streams, result.token_component)
|
|
91
|
+
return [*streams, result.token_component.to_stream_token]
|
|
92
|
+
end
|
|
93
|
+
|
|
81
94
|
# Guarantee the component's signed identity token is refreshed unless the
|
|
82
95
|
# Response opted out (remove/redirect navigate away — handled above). The
|
|
83
96
|
# client reads the next token from the response body (#extractToken), so
|
|
@@ -88,12 +101,23 @@ module Phlex
|
|
|
88
101
|
# replace when a hand-built `with(...)` stream omits it. Idempotent: a
|
|
89
102
|
# Response.replace(self)/update(self) already carries the token, so we
|
|
90
103
|
# don't double the self-render.
|
|
91
|
-
if result.render_self? && streams.none? {
|
|
104
|
+
if result.render_self? && streams.none? { it.include?("data-reactive-token-value") }
|
|
92
105
|
streams = [component.to_stream_replace, *streams]
|
|
93
106
|
end
|
|
94
107
|
streams
|
|
95
108
|
end
|
|
96
109
|
|
|
110
|
+
# True when one of `streams` already carries a fresh token TARGETING this
|
|
111
|
+
# component — i.e. the caller hand-built the actor's own token-bearing
|
|
112
|
+
# stream, so appending to_stream_token would double it. Scoped to the
|
|
113
|
+
# component's target id (not a global substring) so a sibling component's
|
|
114
|
+
# stream, which carries its OWN token for a DIFFERENT target, doesn't fool
|
|
115
|
+
# us into skipping this component's refresh.
|
|
116
|
+
def carries_token_for?(streams, component)
|
|
117
|
+
target = %(target="#{ERB::Util.html_escape(component.id)}")
|
|
118
|
+
streams.any? { it.include?("data-reactive-token-value") && it.include?(target) }
|
|
119
|
+
end
|
|
120
|
+
|
|
97
121
|
# A 200 turbo-stream carrying a namespaced custom action the client turns
|
|
98
122
|
# into Turbo.visit — NOT an HTTP 3xx, which the client hard-bails on
|
|
99
123
|
# (response.redirected). The matching client handler is registered in
|
|
@@ -102,9 +126,9 @@ module Phlex
|
|
|
102
126
|
%(<turbo-stream action="reactive:visit" data-url="#{ERB::Util.html_escape(url)}"></turbo-stream>)
|
|
103
127
|
end
|
|
104
128
|
|
|
105
|
-
def transaction_wrapper(&
|
|
129
|
+
def transaction_wrapper(&)
|
|
106
130
|
if defined?(::ActiveRecord::Base)
|
|
107
|
-
::ActiveRecord::Base.transaction(&
|
|
131
|
+
::ActiveRecord::Base.transaction(&)
|
|
108
132
|
else
|
|
109
133
|
yield
|
|
110
134
|
end
|
|
@@ -139,29 +163,60 @@ module Phlex
|
|
|
139
163
|
|
|
140
164
|
# Coerce a value against a declared type. A type is one of:
|
|
141
165
|
# * a scalar symbol (:string/:integer/:float/:boolean)
|
|
166
|
+
# * :file — a multipart upload (issue #34)
|
|
142
167
|
# * a Hash schema ({ id: :integer, ... }) — nested object
|
|
143
168
|
# * a one-element Array ([:integer] / [{ ... }]) — array of that
|
|
144
169
|
# Arrays accept both a real JSON array and a Rails-style index hash
|
|
145
170
|
# ({ "0" => ..., "1" => ... }), so a fields_for collection works either way.
|
|
146
171
|
def coerce(value, type)
|
|
147
|
-
|
|
172
|
+
case type
|
|
173
|
+
when Array
|
|
148
174
|
coerce_array(value, type.first)
|
|
149
|
-
|
|
175
|
+
when Hash
|
|
150
176
|
coerce_hash(value, type)
|
|
177
|
+
when :file
|
|
178
|
+
coerce_file(value)
|
|
151
179
|
else
|
|
152
180
|
coerce_scalar(value, type)
|
|
153
181
|
end
|
|
154
182
|
end
|
|
155
183
|
|
|
184
|
+
# An uploaded file (issue #34) passes through UNTOUCHED — never .to_s'd,
|
|
185
|
+
# which would corrupt it into a string the action can't attach. Anything
|
|
186
|
+
# that isn't an uploaded file (a forged/malformed scalar, an empty input)
|
|
187
|
+
# returns DROP, so the method's keyword default applies — consistent with
|
|
188
|
+
# the #16 rule that a value that can't be coerced to its type is dropped,
|
|
189
|
+
# not fabricated. Duck-types on UploadedFile's interface (original_filename
|
|
190
|
+
# + a readable IO) rather than naming a class, so a Rack::Test upload, an
|
|
191
|
+
# ActionDispatch upload, and a Falcon multipart body all qualify.
|
|
192
|
+
def coerce_file(value)
|
|
193
|
+
uploaded_file?(value) ? value : DROP
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def uploaded_file?(value)
|
|
197
|
+
value.respond_to?(:original_filename) && value.respond_to?(:read)
|
|
198
|
+
end
|
|
199
|
+
|
|
156
200
|
# A real array (or Rails index hash) coerces element-wise. A malformed
|
|
157
201
|
# present-but-non-array value returns DROP rather than [] — coercing a stray
|
|
158
202
|
# scalar to an empty array would let a bad payload read as an explicit
|
|
159
203
|
# "clear everything" on update!(declared_array:).
|
|
204
|
+
#
|
|
205
|
+
# An ELEMENT that coerces to DROP (e.g. a non-file in a [:file] array, a
|
|
206
|
+
# forged/mixed payload) is rejected from the result — the same rule
|
|
207
|
+
# coerce_hash applies to a dropped value, so the internal DROP sentinel
|
|
208
|
+
# never leaks into the action. A genuinely empty input array stays [] (an
|
|
209
|
+
# explicit empty collection), but an array whose every element drops
|
|
210
|
+
# returns DROP, so the keyword default applies rather than handing the
|
|
211
|
+
# action a surprise [].
|
|
160
212
|
def coerce_array(value, element_type)
|
|
161
213
|
values = array_values(value)
|
|
162
214
|
return DROP if values.nil?
|
|
215
|
+
return [] if values.empty?
|
|
163
216
|
|
|
164
|
-
values.map {
|
|
217
|
+
coerced = values.map { coerce(it, element_type) }
|
|
218
|
+
coerced.reject! { it.equal?(DROP) }
|
|
219
|
+
coerced.empty? ? DROP : coerced
|
|
165
220
|
end
|
|
166
221
|
|
|
167
222
|
# Keep declared keys only (drop undeclared — no mass assignment), recursing
|
|
@@ -195,9 +250,9 @@ module Phlex
|
|
|
195
250
|
def array_values(value)
|
|
196
251
|
return value.to_a if value.is_a?(Array)
|
|
197
252
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
253
|
+
return unless value.respond_to?(:to_unsafe_h) || value.is_a?(Hash)
|
|
254
|
+
|
|
255
|
+
to_param_hash(value).sort_by { |k, _| k.to_i }.map(&:last)
|
|
201
256
|
end
|
|
202
257
|
|
|
203
258
|
# Unwrap ActionController::Parameters (or a plain Hash) to a string-keyed
|
|
@@ -234,13 +289,19 @@ module Phlex
|
|
|
234
289
|
end
|
|
235
290
|
end
|
|
236
291
|
|
|
292
|
+
# Matches each bracket segment in "items_attributes][0][qty]" — the part
|
|
293
|
+
# after the first "[". Hoisted to a frozen constant so coercing a bracketed
|
|
294
|
+
# key doesn't recompile the pattern per key on every request.
|
|
295
|
+
BRACKET_SEGMENT = /[^\[\]]+/
|
|
296
|
+
private_constant :BRACKET_SEGMENT
|
|
297
|
+
|
|
237
298
|
# "invoice[items_attributes][0][qty]" => ["invoice", "items_attributes",
|
|
238
299
|
# "0", "qty"]. A key with no brackets is a single-element path.
|
|
239
300
|
def bracket_path(key)
|
|
240
301
|
return [key] unless key.include?("[")
|
|
241
302
|
|
|
242
303
|
head, rest = key.split("[", 2)
|
|
243
|
-
[head, *rest.scan(
|
|
304
|
+
[head, *rest.scan(BRACKET_SEGMENT)]
|
|
244
305
|
end
|
|
245
306
|
|
|
246
307
|
# Walk/create nested hashes along `path`, then merge `value` at the leaf so
|
|
@@ -270,7 +331,7 @@ module Phlex
|
|
|
270
331
|
# already gates this; defense in depth against constant injection.
|
|
271
332
|
def resolve_component(name)
|
|
272
333
|
klass = name.to_s.safe_constantize
|
|
273
|
-
unless klass
|
|
334
|
+
unless klass.respond_to?(:reactive_action?) && klass.include?(Phlex::Reactive::Component)
|
|
274
335
|
raise Phlex::Reactive::InvalidToken
|
|
275
336
|
end
|
|
276
337
|
|
|
@@ -4,12 +4,16 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
4
4
|
// replaces the per-feature Stimulus controllers you'd otherwise hand-write
|
|
5
5
|
// for interactive components. A component declares its actions in Ruby (via
|
|
6
6
|
// Phlex::Reactive::Component); this controller binds DOM events to a single
|
|
7
|
-
// HTTP round trip and lets Turbo
|
|
7
|
+
// HTTP round trip and lets Turbo apply the re-rendered component back in
|
|
8
|
+
// (replace by default; method="morph" — Response.morph — preserves focus).
|
|
8
9
|
//
|
|
9
10
|
// Wire format (client -> server), POST <action path>, turbo-stream Accept:
|
|
10
|
-
// { token: "<signed identity>", act: "<action>", params: {...} }
|
|
11
|
+
// { token: "<signed identity>", act: "<action>", params: {...} } (JSON)
|
|
11
12
|
// (`act`, not `action`: `action` is a reserved Rails routing param.)
|
|
12
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.
|
|
13
17
|
// The response is a <turbo-stream> that replaces the component by its id.
|
|
14
18
|
//
|
|
15
19
|
// Server -> client live updates use the SAME element id, pushed over the
|
|
@@ -31,9 +35,38 @@ export function registerReactiveVisit() {
|
|
|
31
35
|
if (url) window.Turbo.visit(url, { action: "advance" })
|
|
32
36
|
}
|
|
33
37
|
}
|
|
38
|
+
|
|
39
|
+
// Custom turbo-stream action: a TOKEN-ONLY refresh (issue #30). A partial
|
|
40
|
+
// update (Response.streams / reply.streams) re-renders only PART of a component
|
|
41
|
+
// — so there's no full-self replace to carry the next signed token. The server
|
|
42
|
+
// instead emits `<turbo-stream action="reactive:token" target="<id>"
|
|
43
|
+
// data-reactive-token-value="<fresh>">`. #perform's #extractToken already reads
|
|
44
|
+
// the token out of the response body for the NEXT queued request; this handler
|
|
45
|
+
// keeps the DOM in sync too, writing the attribute onto the root element so the
|
|
46
|
+
// `tokenValue` fallback stays fresh. It's a pure attribute set — no node is
|
|
47
|
+
// replaced — so a focused <input> + caret survive (the whole point: update a
|
|
48
|
+
// total cell without tearing down the field the user is typing in).
|
|
49
|
+
export function registerReactiveToken() {
|
|
50
|
+
const actions = window.Turbo?.StreamActions
|
|
51
|
+
if (!actions || actions["reactive:token"]) return
|
|
52
|
+
actions["reactive:token"] = function () {
|
|
53
|
+
const token = this.getAttribute("data-reactive-token-value")
|
|
54
|
+
const target = this.getAttribute("target")
|
|
55
|
+
if (!token || !target) return
|
|
56
|
+
const el = document.getElementById(target)
|
|
57
|
+
// Stimulus reads the token via the `token` value -> data-reactive-token-value.
|
|
58
|
+
if (el) el.setAttribute("data-reactive-token-value", token)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function registerReactiveActions() {
|
|
63
|
+
registerReactiveVisit()
|
|
64
|
+
registerReactiveToken()
|
|
65
|
+
}
|
|
66
|
+
|
|
34
67
|
if (typeof window !== "undefined") {
|
|
35
|
-
if (window.Turbo)
|
|
36
|
-
else document.addEventListener("turbo:load",
|
|
68
|
+
if (window.Turbo) registerReactiveActions()
|
|
69
|
+
else document.addEventListener("turbo:load", registerReactiveActions, { once: true })
|
|
37
70
|
}
|
|
38
71
|
|
|
39
72
|
// --- Registration guard (issue #26 part 2) -------------------------------
|
|
@@ -89,6 +122,7 @@ export default class extends Controller {
|
|
|
89
122
|
|
|
90
123
|
#tokenCache // freshest token, threaded synchronously across queued requests
|
|
91
124
|
#debounceTimers = new Map() // trigger element -> { timer, flush } pending dispatch
|
|
125
|
+
#actionPathCache // page-stable action path, resolved once per controller
|
|
92
126
|
|
|
93
127
|
// Mark that a reactive controller actually connected, so the registration
|
|
94
128
|
// guard above knows the controller was registered (issue #26 part 2).
|
|
@@ -172,24 +206,34 @@ export default class extends Controller {
|
|
|
172
206
|
|
|
173
207
|
async #perform(action, params) {
|
|
174
208
|
// Auto-collect named field values inside this component so a button-
|
|
175
|
-
// triggered action still receives sibling inputs (Livewire-style)
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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 })
|
|
184
225
|
|
|
185
226
|
this.element.setAttribute("aria-busy", "true")
|
|
186
227
|
|
|
187
228
|
try {
|
|
188
229
|
const headers = {
|
|
189
|
-
"Content-Type": "application/json",
|
|
190
230
|
Accept: "text/vnd.turbo-stream.html",
|
|
191
231
|
"X-CSRF-Token": this.#csrfToken(),
|
|
192
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"
|
|
193
237
|
// Send the pgbus SSE connection id (if subscribed) so the server can
|
|
194
238
|
// exclude this connection from its own broadcast echo — the actor
|
|
195
239
|
// already gets the action's HTTP response. Harmless without pgbus.
|
|
@@ -222,8 +266,10 @@ export default class extends Controller {
|
|
|
222
266
|
// Capture the new token from the response synchronously, so the next
|
|
223
267
|
// queued request uses it without waiting for the async DOM morph.
|
|
224
268
|
this.#currentToken = this.#extractToken(html) ?? this.#currentToken
|
|
225
|
-
// Turbo applies the <turbo-stream> ops
|
|
226
|
-
// focus
|
|
269
|
+
// Turbo applies the <turbo-stream> ops by id. A plain replace is an
|
|
270
|
+
// outerHTML swap (focus on the replaced subtree is lost); a method="morph"
|
|
271
|
+
// replace (Response.morph) or an update morphs in place, preserving the
|
|
272
|
+
// focused input + caret on unchanged nodes — see issue #28.
|
|
227
273
|
window.Turbo.renderStreamMessage(html)
|
|
228
274
|
} catch (error) {
|
|
229
275
|
console.error("[phlex-reactive] action error", error)
|
|
@@ -255,12 +301,22 @@ export default class extends Controller {
|
|
|
255
301
|
return el.closest('[data-controller~="reactive"]') === this.element
|
|
256
302
|
}
|
|
257
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.
|
|
258
309
|
#collectFields() {
|
|
259
310
|
const fields = {}
|
|
260
|
-
|
|
311
|
+
const files = []
|
|
261
312
|
this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
|
|
262
313
|
if (!this.#ownsField(field)) return
|
|
263
|
-
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") {
|
|
264
320
|
fields[field.name] = field.checked
|
|
265
321
|
} else if (field.type === "radio") {
|
|
266
322
|
if (field.checked) fields[field.name] = field.value
|
|
@@ -288,7 +344,71 @@ export default class extends Controller {
|
|
|
288
344
|
fields[name] = el.value ?? el.textContent ?? el.innerHTML ?? ""
|
|
289
345
|
}
|
|
290
346
|
})
|
|
291
|
-
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))
|
|
292
412
|
}
|
|
293
413
|
|
|
294
414
|
#parseParams(raw) {
|
|
@@ -300,13 +420,21 @@ export default class extends Controller {
|
|
|
300
420
|
}
|
|
301
421
|
}
|
|
302
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.
|
|
303
428
|
#actionPath() {
|
|
304
|
-
return (
|
|
429
|
+
return (this.#actionPathCache ??=
|
|
305
430
|
document.querySelector('meta[name="phlex-reactive-action-path"]')?.content ||
|
|
306
|
-
"/reactive/actions"
|
|
307
|
-
)
|
|
431
|
+
"/reactive/actions")
|
|
308
432
|
}
|
|
309
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.
|
|
310
438
|
#csrfToken() {
|
|
311
439
|
return document.querySelector('meta[name="csrf-token"]')?.content ?? ""
|
|
312
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)
|