phlex-reactive 0.15.1 → 0.15.2
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 +89 -0
- data/README.md +72 -9
- data/app/controllers/phlex/reactive/actions_controller.rb +4 -1
- data/app/javascript/phlex/reactive/compute.js +14 -1
- data/app/javascript/phlex/reactive/compute.min.js +2 -2
- data/app/javascript/phlex/reactive/compute.min.js.map +3 -3
- data/app/javascript/phlex/reactive/core.min.js +6 -5
- data/app/javascript/phlex/reactive/core.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/bindings.js +47 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +4 -4
- data/app/javascript/phlex/reactive/runtime.js +337 -13
- data/lib/phlex/reactive/component/helpers.rb +15 -0
- data/lib/phlex/reactive/component/media_binding.rb +85 -0
- data/lib/phlex/reactive/held_fields.rb +87 -0
- data/lib/phlex/reactive/js.rb +142 -29
- data/lib/phlex/reactive/streamable.rb +26 -2
- data/lib/phlex/reactive/test_helpers.rb +11 -2
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +3 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# The fields a morph held over a changed server value (issue #370).
|
|
6
|
+
#
|
|
7
|
+
# Since #338 a morph keeps the value of the field being typed in and writes
|
|
8
|
+
# the render's value as the field's default. When that render differs from
|
|
9
|
+
# the old default, someone else changed the field while the user typed.
|
|
10
|
+
# The client marks such a field and the root's next action request names it
|
|
11
|
+
# in the X-Reactive-Held header (a JSON array of field names). Inside the
|
|
12
|
+
# action, `reactive_held_fields` returns those names, so the app can refuse
|
|
13
|
+
# or merge instead of silently overwriting the other change:
|
|
14
|
+
#
|
|
15
|
+
# def save(title: nil)
|
|
16
|
+
# authorize!(@note, to: :update?)
|
|
17
|
+
# return conflict! if reactive_held_fields.include?("title") && title != @note.title
|
|
18
|
+
# ...
|
|
19
|
+
#
|
|
20
|
+
# The header is UNTRUSTED input, like every request field: it carries names
|
|
21
|
+
# only, never values, and is read as data — capped in bytes, count and
|
|
22
|
+
# length, non-strings dropped, and filtered to the names the running action
|
|
23
|
+
# declares as params (matched on the base name, so `title[en]` counts for a
|
|
24
|
+
# declared `title`). A name the action can't receive can't be overwritten
|
|
25
|
+
# by it. Malformed input reads as nothing held; it never fails a request.
|
|
26
|
+
# It is a hint about what the user saw, not authorization.
|
|
27
|
+
module HeldFields
|
|
28
|
+
HEADER = "X-Reactive-Held"
|
|
29
|
+
|
|
30
|
+
# A raw header longer than this is ignored unparsed.
|
|
31
|
+
MAX_BYTES = 4_096
|
|
32
|
+
# At most this many names are read …
|
|
33
|
+
MAX_NAMES = 64
|
|
34
|
+
# … each at most this long.
|
|
35
|
+
MAX_NAME_LENGTH = 128
|
|
36
|
+
|
|
37
|
+
EMPTY = [].freeze
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
# The held field names during the current action ([] outside one).
|
|
41
|
+
def current
|
|
42
|
+
Thread.current[:phlex_reactive_held_fields] || EMPTY
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Expose `names` for the block, restoring the previous list after.
|
|
46
|
+
def with(names)
|
|
47
|
+
previous = Thread.current[:phlex_reactive_held_fields]
|
|
48
|
+
Thread.current[:phlex_reactive_held_fields] = names
|
|
49
|
+
yield
|
|
50
|
+
ensure
|
|
51
|
+
Thread.current[:phlex_reactive_held_fields] = previous
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The header's names that `action_def` declares, frozen; [] for
|
|
55
|
+
# anything absent, oversized or malformed.
|
|
56
|
+
def parse(raw, action_def)
|
|
57
|
+
return EMPTY if raw.nil? || raw.empty? || raw.bytesize > MAX_BYTES
|
|
58
|
+
|
|
59
|
+
names = JSON.parse(raw)
|
|
60
|
+
names.is_a?(Array) ? filter(names, action_def) : EMPTY
|
|
61
|
+
rescue JSON::ParserError
|
|
62
|
+
EMPTY
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Keep the strings among `names` that `action_def` declares as params.
|
|
66
|
+
def filter(names, action_def)
|
|
67
|
+
declared = declared_names(action_def)
|
|
68
|
+
return EMPTY if declared.empty?
|
|
69
|
+
|
|
70
|
+
names.first(MAX_NAMES).select do
|
|
71
|
+
it.is_a?(String) && it.length <= MAX_NAME_LENGTH && declared.include?(base_name(it))
|
|
72
|
+
end.uniq.freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def declared_names(action_def)
|
|
78
|
+
params = action_def.params
|
|
79
|
+
params.is_a?(Hash) ? params.keys.map(&:to_s) : EMPTY
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# "title" for "title", "title[]" and "title[en]".
|
|
83
|
+
def base_name(name) = name.split("[", 2).first
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -11,10 +11,15 @@ module Phlex
|
|
|
11
11
|
# # -> data-reactive-ops-param='[["toggle",{"to":"#menu"}]]'
|
|
12
12
|
#
|
|
13
13
|
# These are declarative DOM OPERATIONS, not state: nothing is shipped back
|
|
14
|
-
# to the server, nothing is trusted from the client, and
|
|
14
|
+
# to the server, nothing is trusted from the client, and a server
|
|
15
15
|
# re-render of the component resets whatever they toggled (the LiveView
|
|
16
|
-
# JS-commands caveat
|
|
17
|
-
#
|
|
16
|
+
# JS-commands caveat; use a signed action for state the server must know).
|
|
17
|
+
# The exception is `keep: true` (issue #369) on the class, visibility and
|
|
18
|
+
# attribute ops: the client records what the op did to each target, and a
|
|
19
|
+
# MORPH of that element re-applies it, so a selection or an expanded row
|
|
20
|
+
# survives another viewer's broadcast. An element a stream REPLACES is a
|
|
21
|
+
# new element and starts clean. Kept state belongs to one tab, so
|
|
22
|
+
# broadcast_to(js:) refuses the flag.
|
|
18
23
|
#
|
|
19
24
|
# Targets: a CSS selector string is resolved WITHIN the component's root by
|
|
20
25
|
# default (nested reactive roots excluded, issue #15 semantics); `:root`
|
|
@@ -38,10 +43,13 @@ module Phlex
|
|
|
38
43
|
# * the URL set — href/src/srcdoc/action/formaction/xlink:href can carry
|
|
39
44
|
# a `javascript:` payload; setting them from client ops
|
|
40
45
|
# is a navigation/injection surface, not a UI toggle.
|
|
46
|
+
# srcset (issue #373) is a URL list that picks what an
|
|
47
|
+
# <img> loads: copy_media copies it from a server-rendered
|
|
48
|
+
# image instead.
|
|
41
49
|
# * style — inline CSS injection; use classes (add_class/...).
|
|
42
50
|
# The INTENDED surface is class ops plus boolean/state attributes:
|
|
43
51
|
# hidden, disabled, open, selected, aria-*, data-*.
|
|
44
|
-
URL_BEARING_ATTRS = %w[href src srcdoc action formaction xlink:href].freeze
|
|
52
|
+
URL_BEARING_ATTRS = %w[href src srcset srcdoc action formaction xlink:href].freeze
|
|
45
53
|
EVENT_HANDLER_ATTR = /\Aon/i
|
|
46
54
|
|
|
47
55
|
# The attr ops whose args carry a "name" the allowlist must gate. Used to
|
|
@@ -118,31 +126,39 @@ module Phlex
|
|
|
118
126
|
# selector, document-wide under global: true). Pair it with ONE disclosure
|
|
119
127
|
# target — with several, the last one wins.
|
|
120
128
|
# js.toggle("#menu", expanded: "#menu-trigger")
|
|
129
|
+
#
|
|
130
|
+
# `keep:` (issue #369) — on show/hide/toggle, the class ops and the attr
|
|
131
|
+
# ops: a morph of the target re-applies the op's result (the class on or
|
|
132
|
+
# off, `hidden`, the attribute's value or its absence) instead of the
|
|
133
|
+
# server render's. The latest kept op on an element and a class or
|
|
134
|
+
# attribute wins, so "clear all, then select one" works:
|
|
135
|
+
# js.remove_class(".item", "selected", keep: true).add_class("#item-2", "selected", keep: true)
|
|
136
|
+
# With `expanded:`, the trigger's aria-expanded is kept too.
|
|
121
137
|
|
|
122
|
-
def show(to, global: false, transition: nil, expanded: nil)
|
|
123
|
-
append("show", target_args(to, global:, transition:, expanded:))
|
|
138
|
+
def show(to, global: false, transition: nil, expanded: nil, keep: false)
|
|
139
|
+
append("show", target_args(to, global:, transition:, expanded:, keep:))
|
|
124
140
|
end
|
|
125
141
|
|
|
126
|
-
def hide(to, global: false, transition: nil, expanded: nil)
|
|
127
|
-
append("hide", target_args(to, global:, transition:, expanded:))
|
|
142
|
+
def hide(to, global: false, transition: nil, expanded: nil, keep: false)
|
|
143
|
+
append("hide", target_args(to, global:, transition:, expanded:, keep:))
|
|
128
144
|
end
|
|
129
145
|
|
|
130
|
-
def toggle(to, global: false, transition: nil, expanded: nil)
|
|
131
|
-
append("toggle", target_args(to, global:, transition:, expanded:))
|
|
146
|
+
def toggle(to, global: false, transition: nil, expanded: nil, keep: false)
|
|
147
|
+
append("toggle", target_args(to, global:, transition:, expanded:, keep:))
|
|
132
148
|
end
|
|
133
149
|
|
|
134
150
|
# --- Classes ---
|
|
135
151
|
|
|
136
|
-
def add_class(to, *classes, global: false)
|
|
137
|
-
append("add_class", class_args(to, classes, global:))
|
|
152
|
+
def add_class(to, *classes, global: false, keep: false)
|
|
153
|
+
append("add_class", class_args(to, classes, global:, keep:))
|
|
138
154
|
end
|
|
139
155
|
|
|
140
|
-
def remove_class(to, *classes, global: false)
|
|
141
|
-
append("remove_class", class_args(to, classes, global:))
|
|
156
|
+
def remove_class(to, *classes, global: false, keep: false)
|
|
157
|
+
append("remove_class", class_args(to, classes, global:, keep:))
|
|
142
158
|
end
|
|
143
159
|
|
|
144
|
-
def toggle_class(to, *classes, global: false)
|
|
145
|
-
append("toggle_class", class_args(to, classes, global:))
|
|
160
|
+
def toggle_class(to, *classes, global: false, keep: false)
|
|
161
|
+
append("toggle_class", class_args(to, classes, global:, keep:))
|
|
146
162
|
end
|
|
147
163
|
|
|
148
164
|
# --- Attributes (issue #96) — allowlisted names only ---
|
|
@@ -157,19 +173,19 @@ module Phlex
|
|
|
157
173
|
# attribute becomes `on`. For aria-expanded/aria-pressed/data-state:
|
|
158
174
|
# js.toggle_attr("#trigger", "aria-expanded", "true", "false")
|
|
159
175
|
|
|
160
|
-
def set_attr(to, name, value, global: false)
|
|
161
|
-
append("set_attr", attr_args(to, name, global:, value:))
|
|
176
|
+
def set_attr(to, name, value, global: false, keep: false)
|
|
177
|
+
append("set_attr", attr_args(to, name, global:, keep:, value:))
|
|
162
178
|
end
|
|
163
179
|
|
|
164
|
-
def remove_attr(to, name, global: false)
|
|
165
|
-
append("remove_attr", attr_args(to, name, global:))
|
|
180
|
+
def remove_attr(to, name, global: false, keep: false)
|
|
181
|
+
append("remove_attr", attr_args(to, name, global:, keep:))
|
|
166
182
|
end
|
|
167
183
|
|
|
168
|
-
def toggle_attr(to, name, *values, global: false)
|
|
169
|
-
args = attr_args(to, name, global:)
|
|
184
|
+
def toggle_attr(to, name, *values, global: false, keep: false)
|
|
185
|
+
args = attr_args(to, name, global:, keep:)
|
|
170
186
|
return append("toggle_attr", args) if values.empty?
|
|
171
187
|
|
|
172
|
-
append("toggle_attr", args.merge("values" => toggle_values(name, values))
|
|
188
|
+
append("toggle_attr", with_keep(args.except("keep").merge("values" => toggle_values(name, values)), keep))
|
|
173
189
|
end
|
|
174
190
|
|
|
175
191
|
# --- Focus (issue #96) ---
|
|
@@ -256,7 +272,7 @@ module Phlex
|
|
|
256
272
|
# element (the two passes would fight over the same attribute).
|
|
257
273
|
|
|
258
274
|
def paste_into(to, global: false)
|
|
259
|
-
if
|
|
275
|
+
if [:root, ROOT_SENTINEL].include?(to)
|
|
260
276
|
raise ArgumentError,
|
|
261
277
|
"#{self.class}: paste_into targets a field, not the component root — " \
|
|
262
278
|
"pass the input's CSS selector (e.g. js.paste_into(\"[name=code]\"))"
|
|
@@ -265,6 +281,36 @@ module Phlex
|
|
|
265
281
|
append("paste_into", target_args(to, global:))
|
|
266
282
|
end
|
|
267
283
|
|
|
284
|
+
# --- Preview a chosen file (issue #375) ---
|
|
285
|
+
#
|
|
286
|
+
# preview_file(to, into:, name: nil) — on the file input's change, paint
|
|
287
|
+
# the first chosen IMAGE file onto the `into:` img through an object URL
|
|
288
|
+
# the client creates itself (URL.createObjectURL) and revokes on the next
|
|
289
|
+
# choice, on a clear, and when the img leaves the page. The op carries NO
|
|
290
|
+
# URL — there is no parameter for one, and the client never reads a `url`
|
|
291
|
+
# key — so the `src` refusal of set_attr (issue #96) stays whole. `to` is
|
|
292
|
+
# an input[type=file] (an explicit :root is refused); `into:` must
|
|
293
|
+
# resolve to an <img> (anything else warns and is skipped in the
|
|
294
|
+
# browser); `name:` optionally names a node that gets file.name as
|
|
295
|
+
# textContent. An empty selection or a non-image file clears the preview.
|
|
296
|
+
# ACTOR-ONLY like paste_into: refused in broadcast_to(js:) — a broadcast
|
|
297
|
+
# that read every subscriber's file input would be hostile.
|
|
298
|
+
# input(type: "file", id: "photo-file", accept: "image/*",
|
|
299
|
+
# **on_client(:change, js.preview_file("#photo-file", into: "#photo", name: "#photo-name")))
|
|
300
|
+
|
|
301
|
+
def preview_file(to, into:, name: nil, global: false)
|
|
302
|
+
if [:root, ROOT_SENTINEL].include?(to)
|
|
303
|
+
raise ArgumentError,
|
|
304
|
+
"#{self.class}: preview_file reads a file input, not the component root — " \
|
|
305
|
+
"pass the input's CSS selector (e.g. js.preview_file(\"#photo-file\", into: \"#photo\"))"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
args = { "to" => normalize_target(to), "into" => preview_selector(:into, into) }
|
|
309
|
+
args["name"] = preview_selector(:name, name) unless name.nil?
|
|
310
|
+
args["global"] = true if global
|
|
311
|
+
append("preview_file", args.freeze)
|
|
312
|
+
end
|
|
313
|
+
|
|
268
314
|
# --- Text content (issue #159) ---
|
|
269
315
|
#
|
|
270
316
|
# text(to, value) — set the target's textContent (stringified; nil clears).
|
|
@@ -279,6 +325,56 @@ module Phlex
|
|
|
279
325
|
append("text", args.freeze)
|
|
280
326
|
end
|
|
281
327
|
|
|
328
|
+
# --- Copy a server-rendered image (issue #373) ---
|
|
329
|
+
#
|
|
330
|
+
# copy_media(to, from:) — copy src, srcset and alt from the `from` <img>
|
|
331
|
+
# onto the `to` <img>. Both are ELEMENTS (:root or a CSS selector,
|
|
332
|
+
# resolved with the op's own scoping, global: included): the URL is read
|
|
333
|
+
# off the live source element in the browser and is never an argument,
|
|
334
|
+
# so the set_attr refusal of src (issue #96) stays. A missing source, a
|
|
335
|
+
# non-<img> source or one without src clears the target's src, srcset
|
|
336
|
+
# and alt; a target that is not an <img> is skipped. The same URL twice
|
|
337
|
+
# is not written again (no reload). Broadcast-safe: it only reads
|
|
338
|
+
# elements already in the subscriber's document.
|
|
339
|
+
# button(**on_client(:click, js.copy_media("#logo", from: "#logo-007"))) { "North" }
|
|
340
|
+
|
|
341
|
+
def copy_media(to, from:, global: false)
|
|
342
|
+
source = normalize_target(from)
|
|
343
|
+
raise ArgumentError, "#{self.class}: copy_media from: needs a CSS selector, got an empty string" if source.empty?
|
|
344
|
+
|
|
345
|
+
args = { "to" => normalize_target(to), "from" => source }
|
|
346
|
+
args["global"] = true if global
|
|
347
|
+
append("copy_media", args.freeze)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# --- A field's live value (issue #374) ---
|
|
351
|
+
#
|
|
352
|
+
# set_value(to, value) — assign the target field's IDL `.value` (never
|
|
353
|
+
# the attribute: that is set_attr, and a field the user typed in does not
|
|
354
|
+
# follow it), then dispatch a bubbling `input` when the value changed, so
|
|
355
|
+
# reducers and reactive_show see it as typed — the set-value contract
|
|
356
|
+
# paste_into uses (issue #183). Never `change`, never focus (chain
|
|
357
|
+
# js.focus for that), never parsed as HTML. The value is stringified; nil
|
|
358
|
+
# and "" clear the field. The target is an input, textarea or select:
|
|
359
|
+
# :root is refused loudly like paste_into, and anything else (a
|
|
360
|
+
# contenteditable — reactive_editable writes those — a non-field, a
|
|
361
|
+
# non-empty value for a file input) warns and skips in the browser.
|
|
362
|
+
# ACTOR-ONLY like paste_into: a broadcast that overwrote every
|
|
363
|
+
# subscriber's field would be hostile (BROADCAST_REFUSED_OPS).
|
|
364
|
+
# button(**on_client(:click, js.set_value("#notes", "Thanks"))) { "Thanks" }
|
|
365
|
+
|
|
366
|
+
def set_value(to, value, global: false)
|
|
367
|
+
if [:root, ROOT_SENTINEL].include?(to)
|
|
368
|
+
raise ArgumentError,
|
|
369
|
+
"#{self.class}: set_value targets a field, not the component root — " \
|
|
370
|
+
"pass the field's CSS selector (e.g. js.set_value(\"#notes\", \"Thanks\"))"
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
args = { "to" => normalize_target(to), "value" => value.to_s }
|
|
374
|
+
args["global"] = true if global
|
|
375
|
+
append("set_value", args.freeze)
|
|
376
|
+
end
|
|
377
|
+
|
|
282
378
|
# --- Tick or untick a checkbox group (issue #342) ---
|
|
283
379
|
#
|
|
284
380
|
# check_group(group, checked = true) — set every OWNED box of the group
|
|
@@ -395,14 +491,31 @@ module Phlex
|
|
|
395
491
|
self.class.new([*@ops, [name, args].freeze].freeze)
|
|
396
492
|
end
|
|
397
493
|
|
|
398
|
-
def target_args(to, global:, transition: nil, expanded: nil)
|
|
494
|
+
def target_args(to, global:, transition: nil, expanded: nil, keep: false)
|
|
399
495
|
args = { "to" => normalize_target(to) }
|
|
400
496
|
args["global"] = true if global
|
|
401
497
|
args["transition"] = normalize_transition(transition) if transition
|
|
402
498
|
args["expanded"] = normalize_target(expanded) unless expanded.nil?
|
|
499
|
+
with_keep(args, keep)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# keep: (issue #369) rides LAST and only when true, so an op without it
|
|
503
|
+
# serializes byte-identically to before.
|
|
504
|
+
def with_keep(args, keep)
|
|
505
|
+
args["keep"] = true if keep
|
|
403
506
|
args.freeze
|
|
404
507
|
end
|
|
405
508
|
|
|
509
|
+
# preview_file's into:/name: — a non-empty CSS selector String, never
|
|
510
|
+
# :root or its "@root" sentinel (the root is a container, not the img —
|
|
511
|
+
# and a name: on it would replace the whole component with a file name).
|
|
512
|
+
def preview_selector(key, selector)
|
|
513
|
+
return selector if selector.is_a?(String) && !selector.strip.empty? && selector != ROOT_SENTINEL
|
|
514
|
+
|
|
515
|
+
raise ArgumentError,
|
|
516
|
+
"#{self.class}: preview_file #{key}: must be a CSS selector string, got #{selector.inspect}"
|
|
517
|
+
end
|
|
518
|
+
|
|
406
519
|
# The [on, off] pair of a two-value toggle_attr, stringified. Loud on a
|
|
407
520
|
# half-specified pair or one that could never change anything.
|
|
408
521
|
def toggle_values(name, values)
|
|
@@ -422,13 +535,13 @@ module Phlex
|
|
|
422
535
|
|
|
423
536
|
# An attr op's args: the target, the allowlisted name, an optional
|
|
424
537
|
# (stringified) value. The name is validated LOUDLY at build time.
|
|
425
|
-
def attr_args(to, name, global:, value: :__none)
|
|
538
|
+
def attr_args(to, name, global:, keep: false, value: :__none)
|
|
426
539
|
name = name.to_s
|
|
427
540
|
assert_allowed_attr(name)
|
|
428
541
|
args = { "to" => normalize_target(to), "name" => name }
|
|
429
542
|
args["value"] = value.to_s unless value == :__none
|
|
430
543
|
args["global"] = true if global
|
|
431
|
-
args
|
|
544
|
+
with_keep(args, keep)
|
|
432
545
|
end
|
|
433
546
|
|
|
434
547
|
# Build-time attr-name allowlist for the instance builder — delegates to the
|
|
@@ -456,14 +569,14 @@ module Phlex
|
|
|
456
569
|
[transition[:during], transition[:from], transition[:to]].map(&:to_s).freeze
|
|
457
570
|
end
|
|
458
571
|
|
|
459
|
-
def class_args(to, classes, global:)
|
|
572
|
+
def class_args(to, classes, global:, keep: false)
|
|
460
573
|
if classes.empty?
|
|
461
574
|
raise ArgumentError, "#{self.class}: a class op needs at least one class (got none for #{to.inspect})"
|
|
462
575
|
end
|
|
463
576
|
|
|
464
577
|
args = { "to" => normalize_target(to), "classes" => classes.map(&:to_s).freeze }
|
|
465
578
|
args["global"] = true if global
|
|
466
|
-
args
|
|
579
|
+
with_keep(args, keep)
|
|
467
580
|
end
|
|
468
581
|
|
|
469
582
|
# Instance-side alias for the class method (used by the op builder). See
|
|
@@ -40,13 +40,21 @@ module Phlex
|
|
|
40
40
|
# (issue #342) would tick or clear every subscriber's selection; presence
|
|
41
41
|
# (issue #358) would write every subscriber's presence state; show_modal
|
|
42
42
|
# and close_dialog (issue #365) would open or close a modal, and move
|
|
43
|
-
# focus, on every screen at once
|
|
43
|
+
# focus, on every screen at once; set_value (issue #374) would overwrite
|
|
44
|
+
# every subscriber's field; preview_file (issue #375) would read every
|
|
45
|
+
# subscriber's file input. They
|
|
44
46
|
# belong to the actor's own reply (reply.js) or gesture (on_client / a
|
|
45
47
|
# reducer's $ops), never a broadcast. Names mirror Phlex::Reactive::JS's verbs.
|
|
46
48
|
BROADCAST_REFUSED_OPS = %w[
|
|
47
49
|
focus focus_first submit paste_into persist_state persist_clear check_group presence
|
|
48
50
|
show_modal close_dialog
|
|
51
|
+
preview_file
|
|
52
|
+
set_value
|
|
49
53
|
].freeze
|
|
54
|
+
# Refused as a FLAG, on any op (issue #369): keep: records client state in
|
|
55
|
+
# the tab that ran the op, so a broadcast carrying it would plant that
|
|
56
|
+
# state in every subscriber's tab. The op itself broadcasts fine without it.
|
|
57
|
+
BROADCAST_REFUSED_ARG = "keep"
|
|
50
58
|
|
|
51
59
|
# The broadcast_to verb kwargs (issue #185) → their Turbo stream action.
|
|
52
60
|
# SELF-TARGETING verbs derive the target from the component's #id (require a
|
|
@@ -201,14 +209,30 @@ module Phlex
|
|
|
201
209
|
"subscriber's form; broadcasting paste_into reads every subscriber's clipboard; " \
|
|
202
210
|
"broadcasting check_group ticks or clears every subscriber's selection; " \
|
|
203
211
|
"broadcasting presence writes every subscriber's presence state; " \
|
|
212
|
+
"broadcasting set_value overwrites every subscriber's field (render the value instead); " \
|
|
204
213
|
"broadcasting show_modal or close_dialog opens or closes a modal (and moves focus) on " \
|
|
205
|
-
"every screen — render the dialog open or closed, with data: { reactive_modal: true }, instead." \
|
|
214
|
+
"every screen — render the dialog open or closed, with data: { reactive_modal: true }, instead. " \
|
|
215
|
+
"Broadcasting preview_file reads every subscriber's chosen file onto their page. " \
|
|
206
216
|
"These are actor concerns (reply.js / on_client / $ops)."
|
|
207
217
|
end
|
|
218
|
+
refuse_kept_broadcast_ops!(pairs)
|
|
208
219
|
|
|
209
220
|
Phlex::Reactive::Response.js_ops_json(ops)
|
|
210
221
|
end
|
|
211
222
|
|
|
223
|
+
# keep: (issue #369) is per-tab client state: refuse the flag on any op.
|
|
224
|
+
def refuse_kept_broadcast_ops!(pairs)
|
|
225
|
+
kept = pairs.filter_map do |name, args|
|
|
226
|
+
name.to_s if args.is_a?(::Hash) && (args[BROADCAST_REFUSED_ARG] || args[BROADCAST_REFUSED_ARG.to_sym])
|
|
227
|
+
end
|
|
228
|
+
return if kept.empty?
|
|
229
|
+
|
|
230
|
+
raise ArgumentError,
|
|
231
|
+
"broadcast_to(js:) refuses keep: (on #{kept.uniq.join(", ")}) — kept state belongs to the tab " \
|
|
232
|
+
"that ran the op; broadcasting it would plant it in every subscriber's tab. Drop keep: from the " \
|
|
233
|
+
"broadcast, or render the state on the server."
|
|
234
|
+
end
|
|
235
|
+
|
|
212
236
|
# Render a built component to HTML for a broadcast, ALWAYS instrumented
|
|
213
237
|
# (issue #185): a Streamable renders through its own render_component (which
|
|
214
238
|
# fires render.phlex_reactive); a plain component renders through the module
|
|
@@ -93,14 +93,23 @@ module Phlex
|
|
|
93
93
|
# after_commit broadcasts behave.
|
|
94
94
|
# 5. authorization — a registered authorization error RAISES (the endpoint
|
|
95
95
|
# maps it to 403; a unit test asserts the real exception).
|
|
96
|
-
|
|
96
|
+
#
|
|
97
|
+
# `held:` stands in for the X-Reactive-Held header (issue #370): the field
|
|
98
|
+
# names a morph held over a changed server value, filtered to the action's
|
|
99
|
+
# declared params exactly as the endpoint filters them, and returned by
|
|
100
|
+
# reactive_held_fields inside the action. `held` is therefore reserved
|
|
101
|
+
# here: an action param literally named `held` can't be passed through.
|
|
102
|
+
#
|
|
103
|
+
# run_reactive(form, :save, held: ["title"], title: "Draft by B")
|
|
104
|
+
def run_reactive(component, action, held: [], **params)
|
|
97
105
|
klass = component.class
|
|
98
106
|
action_def = klass.reactive_actions[action.to_sym] || raise_undeclared(klass, action)
|
|
99
107
|
|
|
100
108
|
rebuilt = rebuild_from_identity(component, klass)
|
|
101
109
|
coerced = action_def.schema.coerce(stringify_params(params))
|
|
102
110
|
|
|
103
|
-
|
|
111
|
+
held_fields = Phlex::Reactive::HeldFields.parse(Array(held).map(&:to_s).to_json, action_def)
|
|
112
|
+
returned = Phlex::Reactive::HeldFields.with(held_fields) { run_reactive_action(rebuilt, action_def, coerced) }
|
|
104
113
|
Result.new(returned:, component: rebuilt)
|
|
105
114
|
end
|
|
106
115
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: phlex-reactive
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.15.
|
|
4
|
+
version: 0.15.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -198,6 +198,7 @@ files:
|
|
|
198
198
|
- lib/phlex/reactive/component/helpers.rb
|
|
199
199
|
- lib/phlex/reactive/component/identity.rb
|
|
200
200
|
- lib/phlex/reactive/component/lazy.rb
|
|
201
|
+
- lib/phlex/reactive/component/media_binding.rb
|
|
201
202
|
- lib/phlex/reactive/component/presence_declaration.rb
|
|
202
203
|
- lib/phlex/reactive/component/registry.rb
|
|
203
204
|
- lib/phlex/reactive/component/selection.rb
|
|
@@ -210,6 +211,7 @@ files:
|
|
|
210
211
|
- lib/phlex/reactive/effects.rb
|
|
211
212
|
- lib/phlex/reactive/engine.rb
|
|
212
213
|
- lib/phlex/reactive/fragment.rb
|
|
214
|
+
- lib/phlex/reactive/held_fields.rb
|
|
213
215
|
- lib/phlex/reactive/inspector.rb
|
|
214
216
|
- lib/phlex/reactive/inspector/report.rb
|
|
215
217
|
- lib/phlex/reactive/js.rb
|