phlex-reactive 0.14.3 → 0.15.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 +107 -0
- data/README.md +258 -5
- data/app/controllers/phlex/reactive/actions_controller.rb +28 -18
- data/app/javascript/phlex/reactive/core.js +7 -1
- data/app/javascript/phlex/reactive/core.min.js +6 -2
- data/app/javascript/phlex/reactive/core.min.js.map +4 -4
- data/app/javascript/phlex/reactive/early.js +28 -9
- data/app/javascript/phlex/reactive/early.min.js +2 -2
- data/app/javascript/phlex/reactive/early.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/bindings.js +188 -107
- 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/features/form.js +75 -46
- data/app/javascript/phlex/reactive/features/form.min.js +2 -2
- data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/persist.js +12 -3
- data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
- data/app/javascript/phlex/reactive/features/persist.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/presence.js +205 -0
- data/app/javascript/phlex/reactive/features/presence.min.js +4 -0
- data/app/javascript/phlex/reactive/features/presence.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/sortable.js +463 -0
- data/app/javascript/phlex/reactive/features/sortable.min.js +4 -0
- data/app/javascript/phlex/reactive/features/sortable.min.js.map +10 -0
- data/app/javascript/phlex/reactive/inspect.js +5 -1
- data/app/javascript/phlex/reactive/inspect.min.js +2 -2
- data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +5 -1
- data/app/javascript/phlex/reactive/reactive_controller.min.js +6 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -8
- data/app/javascript/phlex/reactive/runtime.js +286 -18
- data/lib/phlex/reactive/component/dsl.rb +5 -0
- data/lib/phlex/reactive/component/editable.rb +78 -0
- data/lib/phlex/reactive/component/helpers.rb +81 -8
- data/lib/phlex/reactive/component/presence_declaration.rb +118 -0
- data/lib/phlex/reactive/component/registry.rb +3 -1
- data/lib/phlex/reactive/component/sortable.rb +212 -0
- data/lib/phlex/reactive/component.rb +1 -0
- data/lib/phlex/reactive/defer.rb +5 -0
- data/lib/phlex/reactive/engine.rb +9 -4
- data/lib/phlex/reactive/js.rb +24 -0
- data/lib/phlex/reactive/presence/component_methods.rb +130 -0
- data/lib/phlex/reactive/presence/roster.rb +90 -0
- data/lib/phlex/reactive/presence/transport.rb +154 -0
- data/lib/phlex/reactive/presence.rb +162 -0
- data/lib/phlex/reactive/presence_render_job.rb +26 -0
- data/lib/phlex/reactive/shortcut.rb +105 -0
- data/lib/phlex/reactive/streamable.rb +7 -3
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +10 -0
- metadata +16 -1
|
@@ -13,6 +13,8 @@ module Phlex
|
|
|
13
13
|
module Helpers
|
|
14
14
|
extend ActiveSupport::Concern
|
|
15
15
|
include Selection
|
|
16
|
+
include Sortable
|
|
17
|
+
include Editable
|
|
16
18
|
|
|
17
19
|
# The acting client's SSE connection id during the current action (nil
|
|
18
20
|
# outside an action, or when the client isn't subscribed to a stream).
|
|
@@ -125,6 +127,10 @@ module Phlex
|
|
|
125
127
|
(on_complete = self.class.reactive_on_complete_attr)
|
|
126
128
|
data[:reactive_on_complete] = on_complete
|
|
127
129
|
end
|
|
130
|
+
# Presence (issue #358): the signed src of the roster's presence
|
|
131
|
+
# stream, from which the client mints its subscription. Only a class
|
|
132
|
+
# that declares reactive_presence has the method.
|
|
133
|
+
data.merge!(reactive_presence_data) if respond_to?(:reactive_presence_data)
|
|
128
134
|
{ data: }
|
|
129
135
|
end
|
|
130
136
|
|
|
@@ -278,6 +284,17 @@ module Phlex
|
|
|
278
284
|
# (trailing-edge) — passing both raises ArgumentError.
|
|
279
285
|
# div(**mix(reactive_root, on(:track, event: "scroll", window: true, throttle: 250)))
|
|
280
286
|
#
|
|
287
|
+
# `shortcut:` (issue #357, also RESERVED) binds a page-level keyboard
|
|
288
|
+
# chord: a window keydown whose `mod` is meta on Apple platforms and
|
|
289
|
+
# ctrl elsewhere (rendered as a meta twin and a ctrl twin; the client
|
|
290
|
+
# runs the one for its platform). Unlike a plain window binding, the
|
|
291
|
+
# client prevents the chord's native default, and it skips the chord
|
|
292
|
+
# while focus is in an input, textarea, select or contenteditable
|
|
293
|
+
# (native text undo keeps working) unless the Hash form opts in. It
|
|
294
|
+
# decides event:, window: and outside:, so passing one raises.
|
|
295
|
+
# span(**on(:undo, shortcut: "mod+z"))
|
|
296
|
+
# span(**on(:close, shortcut: { keys: "esc", in_editable: true }))
|
|
297
|
+
#
|
|
281
298
|
# `optimistic:` (issue #98) — a small, ALWAYS-REVERSIBLE vocabulary of
|
|
282
299
|
# COSMETIC hints the client applies the instant the trigger fires and
|
|
283
300
|
# REVERTS if the round trip fails, so a click/toggle gives instant feedback
|
|
@@ -322,9 +339,9 @@ module Phlex
|
|
|
322
339
|
# busy_on for scoped indicators.
|
|
323
340
|
# button(**on(:save, busy: "Saving…")) { "Save" }
|
|
324
341
|
# button(**on(:destroy, confirm: "Sure?", busy: { add_class: "opacity-50" })) { "Delete" }
|
|
325
|
-
def on(action_name, event:
|
|
342
|
+
def on(action_name, event: nil, debounce: nil, throttle: nil, confirm: nil,
|
|
326
343
|
window: false, once: false, outside: false, optimistic: nil, busy: nil,
|
|
327
|
-
**params)
|
|
344
|
+
shortcut: nil, **params)
|
|
328
345
|
reject_removed_on_kwargs!(action_name, params)
|
|
329
346
|
# A typo'd or forgotten action renders fine and only surfaces as an
|
|
330
347
|
# opaque 403 at CLICK time (the endpoint's default-deny). Under
|
|
@@ -360,8 +377,11 @@ module Phlex
|
|
|
360
377
|
"exclusive (debounce is trailing-edge, throttle is leading-edge); pick one"
|
|
361
378
|
end
|
|
362
379
|
|
|
363
|
-
|
|
364
|
-
|
|
380
|
+
# Guarded here, not inside: building the caller label allocates (on() is a hot path).
|
|
381
|
+
shortcut = shortcut_for!("on(#{action_name.inspect})", shortcut, event:, window:, outside:) unless shortcut.nil?
|
|
382
|
+
event ||= "click"
|
|
383
|
+
window_bound = window || outside || !shortcut.nil?
|
|
384
|
+
action = trigger_descriptors(event, shortcut, window_bound, "dispatch", once)
|
|
365
385
|
attrs = {
|
|
366
386
|
data: {
|
|
367
387
|
action:,
|
|
@@ -382,6 +402,9 @@ module Phlex
|
|
|
382
402
|
# The client decides preventDefault behavior from event.params (never by
|
|
383
403
|
# sniffing the descriptor), so EVERY window binding flags the param.
|
|
384
404
|
attrs[:data][:reactive_window_param] = "true" if window_bound
|
|
405
|
+
# Issue #357: the client reads the shortcut to pick the twin for its
|
|
406
|
+
# platform, skip editable targets and prevent the chord.
|
|
407
|
+
attrs[:data][:reactive_shortcut_param] = shortcut_param_attr(shortcut) if shortcut
|
|
385
408
|
# Force type="button" for click triggers so a bare button inside a <form>
|
|
386
409
|
# can't submit it — EXCEPT when checked: :keep is declared: that hint's
|
|
387
410
|
# whole point is to let a click-bound checkbox/radio flip natively, and a
|
|
@@ -404,7 +427,9 @@ module Phlex
|
|
|
404
427
|
# modifiers (#80): outside-click-to-close a dropdown is
|
|
405
428
|
# div(**mix(reactive_root, on_client(:click, js.hide("#menu"), outside: true)))
|
|
406
429
|
# Window-bound triggers are never preventDefault-ed by the client and skip
|
|
407
|
-
# the forced type="button".
|
|
430
|
+
# the forced type="button". `shortcut:` (issue #357) works as on()'s, on
|
|
431
|
+
# a bare :keydown only:
|
|
432
|
+
# div(**mix(reactive_root, on_client(:keydown, js.toggle("#palette"), shortcut: "mod+k")))
|
|
408
433
|
#
|
|
409
434
|
# Issue #271: each call emits ONE self-describing binding record —
|
|
410
435
|
# data-reactive-ops-param='{"on":"click","window":true,"outside":true,"ops":[…]}'
|
|
@@ -435,7 +460,8 @@ module Phlex
|
|
|
435
460
|
# Validation is loud: only a non-empty Phlex::Reactive::JS chain is
|
|
436
461
|
# accepted — a dead trigger should fail at render, not no-op in the
|
|
437
462
|
# browser.
|
|
438
|
-
def on_client(event, ops, window: false, once: false, outside: false, confirm: nil, detail: nil
|
|
463
|
+
def on_client(event, ops, window: false, once: false, outside: false, confirm: nil, detail: nil,
|
|
464
|
+
shortcut: nil)
|
|
439
465
|
unless ops.is_a?(Phlex::Reactive::JS)
|
|
440
466
|
raise ArgumentError,
|
|
441
467
|
"on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
|
|
@@ -452,11 +478,15 @@ module Phlex
|
|
|
452
478
|
"submit event this trigger is bound to. Bind the submit op to another event " \
|
|
453
479
|
"(change/input), or gate it behind a reducer's $ops / reactive_on_complete."
|
|
454
480
|
end
|
|
455
|
-
|
|
481
|
+
shortcut = client_shortcut!(event, shortcut, window:, outside:)
|
|
482
|
+
window_bound = window || outside || !shortcut.nil?
|
|
456
483
|
record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:)
|
|
484
|
+
# The record's `on` stays the bare keydown: the client reads a key
|
|
485
|
+
# filter from `on`, and `mod` is not a key.
|
|
486
|
+
record["shortcut"] = shortcut.param if shortcut
|
|
457
487
|
attrs = {
|
|
458
488
|
data: {
|
|
459
|
-
action:
|
|
489
|
+
action: trigger_descriptors(event, shortcut, window_bound, "runOps", once),
|
|
460
490
|
# Space-free (issue #271): compact JSON has no structural
|
|
461
491
|
# whitespace, so every space is inside a string, where \u0020
|
|
462
492
|
# parses to the same string — and mix's space-join stays splittable.
|
|
@@ -1730,6 +1760,49 @@ module Phlex
|
|
|
1730
1760
|
"on(#{action_name.inspect}) #{key}: was removed in issue #181 — use #{REMOVED_ON_KWARGS[key]}"
|
|
1731
1761
|
end
|
|
1732
1762
|
|
|
1763
|
+
# Issue #357: parse on()'s shortcut: (nil when absent). A shortcut
|
|
1764
|
+
# decides the event, the window binding and the containment rule, so
|
|
1765
|
+
# an explicit event:/window:/outside: next to it is a mistake.
|
|
1766
|
+
def shortcut_for!(caller, shortcut, event:, window:, outside:)
|
|
1767
|
+
{ "event:" => event, "window:" => window, "outside:" => outside }.each do |name, value|
|
|
1768
|
+
next unless value
|
|
1769
|
+
|
|
1770
|
+
raise ArgumentError,
|
|
1771
|
+
"#{caller} got shortcut: with #{name} — a shortcut is always a window keydown " \
|
|
1772
|
+
"binding; drop #{name}"
|
|
1773
|
+
end
|
|
1774
|
+
Phlex::Reactive::Shortcut.parse(shortcut)
|
|
1775
|
+
end
|
|
1776
|
+
|
|
1777
|
+
# on_client's event is positional: with shortcut: it must be the bare
|
|
1778
|
+
# keydown (the key filter comes from the shortcut).
|
|
1779
|
+
def client_shortcut!(event, shortcut, window:, outside:)
|
|
1780
|
+
return if shortcut.nil?
|
|
1781
|
+
|
|
1782
|
+
unless event == "keydown"
|
|
1783
|
+
raise ArgumentError,
|
|
1784
|
+
"on_client(#{event.inspect}) got shortcut: — bind it to :keydown " \
|
|
1785
|
+
"(on_client(:keydown, ops, shortcut: \"mod+k\")); the key comes from the shortcut"
|
|
1786
|
+
end
|
|
1787
|
+
shortcut_for!("on_client(:keydown)", shortcut, event: nil, window:, outside:)
|
|
1788
|
+
end
|
|
1789
|
+
|
|
1790
|
+
# The data-action descriptors of an on()/on_client trigger: one, or a
|
|
1791
|
+
# shortcut's meta and ctrl twins (space-joined, as mix joins them).
|
|
1792
|
+
# The common, shortcut-less path is ONE interpolation (on() is a hot path).
|
|
1793
|
+
def trigger_descriptors(event, shortcut, window_bound, method, once)
|
|
1794
|
+
return "#{event}#{"@window" if window_bound}->reactive##{method}#{":once" if once}" unless shortcut
|
|
1795
|
+
|
|
1796
|
+
shortcut.filters.map { "keydown.#{it}@window->reactive##{method}#{":once" if once}" }.join(" ")
|
|
1797
|
+
end
|
|
1798
|
+
|
|
1799
|
+
# A Hash param is JSON (Stimulus's param reader parses it back into an
|
|
1800
|
+
# object); the String form stays a plain string.
|
|
1801
|
+
def shortcut_param_attr(shortcut)
|
|
1802
|
+
param = shortcut.param
|
|
1803
|
+
param.is_a?(Hash) ? param.to_json : param
|
|
1804
|
+
end
|
|
1805
|
+
|
|
1733
1806
|
# Normalize + validate a pending-state hint (issue #181), returning its
|
|
1734
1807
|
# JSON wire form. ONE vocabulary + ONE normalizer for both optimistic: (kind
|
|
1735
1808
|
# :optimistic, reverts on FAILURE) and busy: (kind :busy, reverts on
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# The reactive_presence macro (issue #358). Not named `Presence`: this
|
|
7
|
+
# module is in every reactive component's constant lookup, where a bare
|
|
8
|
+
# noun would shadow a host app's kit component of that name (the #233
|
|
9
|
+
# rule). The behaviour it adds lives in Phlex::Reactive::Presence.
|
|
10
|
+
module PresenceDeclaration
|
|
11
|
+
extend ActiveSupport::Concern
|
|
12
|
+
|
|
13
|
+
# A presence name is the last part of the stream name, so it is one
|
|
14
|
+
# identifier: no colon (the stream-name separator), no spaces.
|
|
15
|
+
PRESENCE_NAME = /\A[a-z][a-z0-9_]*\z/
|
|
16
|
+
|
|
17
|
+
class_methods do
|
|
18
|
+
# Declare this component the roster of a presence stream:
|
|
19
|
+
# [record, :presence, name], or [*key, :presence, name] for a
|
|
20
|
+
# record-less component.
|
|
21
|
+
#
|
|
22
|
+
# reactive_record :board
|
|
23
|
+
# reactive_presence :viewers, state: { selected: :integer }, ttl: 45
|
|
24
|
+
#
|
|
25
|
+
# `state:` is each tab's ephemeral state, typed with the same table as
|
|
26
|
+
# action params; undeclared keys are dropped. `ttl:` is how many
|
|
27
|
+
# seconds a silent tab survives. Adds the presence_set action (what
|
|
28
|
+
# js.presence sends) and `presence` (the roster) to the component.
|
|
29
|
+
def reactive_presence(name, state: {}, ttl: Phlex::Reactive::Presence::DEFAULT_TTL, key: nil)
|
|
30
|
+
require_server_actions!(:reactive_presence)
|
|
31
|
+
declaration = build_presence_declaration(name, state, ttl, key)
|
|
32
|
+
refuse_dormant_presence! if reactive_dormant?
|
|
33
|
+
# The name's fit in pgbus's stream-name budget is configuration (the
|
|
34
|
+
# queue prefix), so it is refused here; the render re-checks it.
|
|
35
|
+
transport = Phlex::Reactive::Presence::Transport
|
|
36
|
+
if transport.wired? && !transport.fits?(declaration.name)
|
|
37
|
+
raise Phlex::Reactive::ConfigurationError, transport.over_budget_message(self, declaration.name)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The generated action. Exempt from verify_authorized: the signed
|
|
41
|
+
# token already proves the actor rendered this root, and the write
|
|
42
|
+
# is typed, default-deny, ephemeral and lands only on the actor's own
|
|
43
|
+
# row (keyed by its secret SSE connection id). A component that needs
|
|
44
|
+
# a check defines authorize_presence_state!(**state); the action runs
|
|
45
|
+
# it first.
|
|
46
|
+
action :presence_set, params: declaration.state
|
|
47
|
+
skip_verify_authorized :presence_set
|
|
48
|
+
Registry.write_scalar(self, :presence, declaration)
|
|
49
|
+
include Phlex::Reactive::Presence::ComponentMethods
|
|
50
|
+
|
|
51
|
+
# Class load runs before a database exists: the signatures alone gate it.
|
|
52
|
+
transport.register_ttl(declaration.name, ttl) if transport.wired?
|
|
53
|
+
declaration
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The Phlex::Reactive::Presence::Declaration, or nil.
|
|
57
|
+
def reactive_presence_declaration
|
|
58
|
+
Registry.resolve_scalar(self, :presence, :reactive_presence_declaration)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The presence stream name a roster of `record` subscribes to — what
|
|
62
|
+
# streams_authorize sees. For a strict per-record check at connect
|
|
63
|
+
# time: compare it with the key of each record the user may view.
|
|
64
|
+
# A record-less roster (key:) ignores `record`.
|
|
65
|
+
def reactive_presence_stream_name_for(record = nil)
|
|
66
|
+
declaration = reactive_presence_declaration
|
|
67
|
+
subject = declaration.key || [record]
|
|
68
|
+
Phlex::Reactive::Presence::Transport.stream_name([*subject, :presence, declaration.name])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def build_presence_declaration(name, state, ttl, key)
|
|
74
|
+
name = name.to_s
|
|
75
|
+
unless PRESENCE_NAME.match?(name)
|
|
76
|
+
raise ArgumentError,
|
|
77
|
+
"#{self.name}: reactive_presence name must be a lowercase identifier (it ends the stream " \
|
|
78
|
+
"name), got #{name.inspect}"
|
|
79
|
+
end
|
|
80
|
+
unless ttl.is_a?(Integer) && ttl.positive?
|
|
81
|
+
raise ArgumentError, "#{self.name}: reactive_presence ttl: must be a positive Integer (seconds), " \
|
|
82
|
+
"got #{ttl.inspect}"
|
|
83
|
+
end
|
|
84
|
+
refuse_ttl_within_heartbeat!(ttl)
|
|
85
|
+
|
|
86
|
+
state = state.transform_keys(&:to_sym)
|
|
87
|
+
if state.key?(Phlex::Reactive::Presence::NOTE_KEY.to_sym)
|
|
88
|
+
raise ArgumentError,
|
|
89
|
+
"#{self.name}: reactive_presence state key #{Phlex::Reactive::Presence::NOTE_KEY} is reserved"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
Phlex::Reactive::Presence::Declaration.new(
|
|
93
|
+
name: name.to_sym, state: state.freeze, ttl:, key: key && Array(key).dup.freeze
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A ttl at or below the heartbeat would expire a live tab between two
|
|
98
|
+
# beats: its row would vanish and come back on the next one.
|
|
99
|
+
def refuse_ttl_within_heartbeat!(ttl)
|
|
100
|
+
heartbeat = Phlex::Reactive::Presence::Transport.heartbeat_interval
|
|
101
|
+
return if ttl > heartbeat
|
|
102
|
+
|
|
103
|
+
raise ArgumentError,
|
|
104
|
+
"#{name}: reactive_presence ttl: #{ttl} is at or below the transport's heartbeat interval " \
|
|
105
|
+
"(#{heartbeat} s), so a live tab would expire between two beats. Use more than #{heartbeat} " \
|
|
106
|
+
"(two missed beats is a good floor)."
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def refuse_dormant_presence!
|
|
110
|
+
raise ArgumentError,
|
|
111
|
+
"#{name}: reactive_presence cannot be dormant — a dormant root never connects, so its tab " \
|
|
112
|
+
"would never join the stream. Drop reactive_dormant on this component."
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -66,7 +66,9 @@ module Phlex
|
|
|
66
66
|
# verify_authorized opt-out (issue #168): a scalar bare flag (skip the
|
|
67
67
|
# WHOLE component) plus a list of named actions.
|
|
68
68
|
skip_all: :@reactive_own_skip_all,
|
|
69
|
-
skip_actions: :@reactive_own_skip_actions
|
|
69
|
+
skip_actions: :@reactive_own_skip_actions,
|
|
70
|
+
# Presence roster — reactive_presence (issue #358).
|
|
71
|
+
presence: :@reactive_own_presence
|
|
70
72
|
}.freeze
|
|
71
73
|
|
|
72
74
|
# The identity-side hot-path memos swept by bump! (see the contract
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# Sortable lists (issue #350): drag rows to reorder a list, or drag a row
|
|
7
|
+
# in from another list, and ONE declared action receives the row and its
|
|
8
|
+
# new position — through the normal action pipeline (the signed token,
|
|
9
|
+
# the request queue, busy:, the reply).
|
|
10
|
+
#
|
|
11
|
+
# ul(**mix(reactive_root, reactive_sortable(:move, handle: "[data-drag-handle]"))) do
|
|
12
|
+
# @list.tasks.each do |task|
|
|
13
|
+
# li(id: dom_id(task), **reactive_sortable_item(task.id)) do
|
|
14
|
+
# span(data: { drag_handle: true }) { "⠿" }
|
|
15
|
+
# plain task.title
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# On drop the client sends the action with these params (declare the
|
|
21
|
+
# ones you want; default-deny drops the rest):
|
|
22
|
+
#
|
|
23
|
+
# item the dragged row's key (reactive_sortable_item(key))
|
|
24
|
+
# position its 0-based index in the target list after the drop
|
|
25
|
+
# after the key of the row it now follows, nil when it is first
|
|
26
|
+
# from "self" for a reorder, else the name of the list it came from
|
|
27
|
+
#
|
|
28
|
+
# The row moves in the DOM at once and moves back when the action fails
|
|
29
|
+
# (or its reply does not render the list). Rows can be dragged with a
|
|
30
|
+
# mouse, a pen or a finger (a 200 ms press), and with the keyboard: a
|
|
31
|
+
# focused row picks up on Space/Enter, moves with the arrow keys, drops
|
|
32
|
+
# on Space/Enter and goes back on Escape, each step announced through a
|
|
33
|
+
# live region in the words of `messages:`.
|
|
34
|
+
#
|
|
35
|
+
# The drag itself is the client's sortable feature module
|
|
36
|
+
# (features/sortable.js); a page without a sortable loads none of it on
|
|
37
|
+
# the split client.
|
|
38
|
+
module Sortable
|
|
39
|
+
# The announcement words, {position} and {count} filled in by the
|
|
40
|
+
# client. Override any of them (a translation) with messages:.
|
|
41
|
+
SORTABLE_MESSAGES = {
|
|
42
|
+
picked: "Picked up item {position} of {count}",
|
|
43
|
+
moved: "Moved to position {position} of {count}",
|
|
44
|
+
dropped: "Dropped",
|
|
45
|
+
cancelled: "Cancelled"
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
# The params the client fills in on drop: a static params: may not set them.
|
|
49
|
+
SORTABLE_DROP_PARAMS = %i[item position after from].freeze
|
|
50
|
+
|
|
51
|
+
# A sortable list. `action` is called on drop.
|
|
52
|
+
#
|
|
53
|
+
# group: links lists (drag between them) and sources to targets
|
|
54
|
+
# handle: a CSS selector, resolved inside each row; only it starts a
|
|
55
|
+
# pointer drag. Without it the whole row drags.
|
|
56
|
+
# name: what a row dragged OUT of this list into another reports
|
|
57
|
+
# as `from` (and what that list's from: map keys on)
|
|
58
|
+
# from: a drag-in calls another action: from: { "templates" => :insert }
|
|
59
|
+
# params: static params sent with every drop (e.g. the lane a list is)
|
|
60
|
+
# disabled: refuse every drag from or into this list
|
|
61
|
+
# busy: on(busy:)'s vocabulary, applied to the list while pending
|
|
62
|
+
# messages: the live-region words, merged over SORTABLE_MESSAGES
|
|
63
|
+
def reactive_sortable(action, group: nil, handle: nil, name: nil, from: nil, params: nil,
|
|
64
|
+
disabled: false, busy: nil, messages: nil)
|
|
65
|
+
sortable_requires_component!
|
|
66
|
+
action = sortable_action!(action)
|
|
67
|
+
data = { reactive_sortable: action }
|
|
68
|
+
data[:reactive_sortable_group] = sortable_name!(:group, group) unless group.nil?
|
|
69
|
+
data[:reactive_sortable_handle] = sortable_handle!(handle) unless handle.nil?
|
|
70
|
+
data[:reactive_sortable_name] = sortable_list_name!(name) unless name.nil?
|
|
71
|
+
data[:reactive_sortable_from] = sortable_from!(from) unless from.nil?
|
|
72
|
+
data[:reactive_sortable_params] = sortable_params!(params) unless params.nil?
|
|
73
|
+
data[:reactive_sortable_disabled] = "true" if disabled
|
|
74
|
+
data[:reactive_sortable_busy] = pending_hint_json(busy, action, :busy) if busy
|
|
75
|
+
data[:reactive_sortable_messages] = sortable_messages!(messages)
|
|
76
|
+
{ data: }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A list whose rows are COPIED into any sortable of the same group,
|
|
80
|
+
# never moved. The target's action (or its from: mapping for `name`)
|
|
81
|
+
# decides what lands, and its reply renders the new row. Needs no
|
|
82
|
+
# token: it works on a ClientBindings root.
|
|
83
|
+
def reactive_sortable_source(group:, name: "source")
|
|
84
|
+
{ data: { reactive_sortable_source: sortable_list_name!(name),
|
|
85
|
+
reactive_sortable_group: sortable_name!(:group, group) } }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A row and its key — a String or an Integer, sent as-is (JSON-typed).
|
|
89
|
+
# The row is focusable, for the keyboard model (unless disabled).
|
|
90
|
+
def reactive_sortable_item(key, disabled: false)
|
|
91
|
+
unless (key.is_a?(String) && !key.strip.empty?) || key.is_a?(Integer)
|
|
92
|
+
raise ArgumentError,
|
|
93
|
+
"reactive_sortable_item key must be a String or an Integer (the row's id), got #{key.inspect}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
data = { reactive_sortable_item: key.to_json }
|
|
97
|
+
# A disabled row cannot be picked up, so it stays out of the tab order.
|
|
98
|
+
return { data: data.merge(reactive_sortable_disabled: "true") } if disabled
|
|
99
|
+
|
|
100
|
+
{ tabindex: 0, data: }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def sortable_requires_component!
|
|
106
|
+
return if self.class.include?(Phlex::Reactive::Component::Identity)
|
|
107
|
+
|
|
108
|
+
raise ArgumentError,
|
|
109
|
+
"reactive_sortable needs the full Phlex::Reactive::Component — a drop calls a server action, " \
|
|
110
|
+
"which Phlex::Reactive::ClientBindings has no token for. A copy-only list on a client-only " \
|
|
111
|
+
"root is reactive_sortable_source(group:)."
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The on() courtesy (issue #105): an undeclared action fails at render
|
|
115
|
+
# under verbose_errors, not as a 403 at drop time.
|
|
116
|
+
def sortable_action!(action)
|
|
117
|
+
if action.nil? || action.to_s.strip.empty?
|
|
118
|
+
raise ArgumentError, "reactive_sortable needs an action to call on drop, e.g. reactive_sortable(:move)"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
if Phlex::Reactive.verbose_errors &&
|
|
122
|
+
(actions = self.class.reactive_actions).any? && !actions.key?(action.to_sym)
|
|
123
|
+
raise Phlex::Reactive::Error,
|
|
124
|
+
"#{self.class} has no declared action #{action.to_sym.inspect} (declared: #{actions.keys.inspect})"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
action.to_s
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def sortable_name!(option, value)
|
|
131
|
+
unless (value.is_a?(String) || value.is_a?(Symbol)) && !value.to_s.strip.empty?
|
|
132
|
+
raise ArgumentError, "reactive_sortable #{option}: needs a name (a String or Symbol), got #{value.inspect}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
value.to_s
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# "self" is what a reorder within one list reports as `from`.
|
|
139
|
+
def sortable_list_name!(value)
|
|
140
|
+
name = sortable_name!(:name, value)
|
|
141
|
+
if name == "self"
|
|
142
|
+
raise ArgumentError,
|
|
143
|
+
'reactive_sortable name: "self" is reserved — it is the `from` of a reorder within one list'
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
name
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def sortable_handle!(handle)
|
|
150
|
+
selector = handle.to_s
|
|
151
|
+
if handle == true || handle == false || selector.strip.empty?
|
|
152
|
+
raise ArgumentError,
|
|
153
|
+
"reactive_sortable handle: needs a CSS selector resolved inside each row " \
|
|
154
|
+
"(e.g. \"[data-drag-handle]\"), got #{handle.inspect}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
selector
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def sortable_from!(from)
|
|
161
|
+
valid = from.is_a?(Hash) && from.any? && from.keys.all? { !it.to_s.strip.empty? }
|
|
162
|
+
unless valid
|
|
163
|
+
raise ArgumentError,
|
|
164
|
+
"reactive_sortable from: maps a source list's name to the action a drag-in from it calls, " \
|
|
165
|
+
"e.g. from: { \"templates\" => :insert }, got #{from.inspect}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
from.to_h { |list, action| [sortable_list_name!(list), sortable_action!(action)] }.to_json
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def sortable_params!(params)
|
|
172
|
+
raise ArgumentError, "reactive_sortable params: must be a Hash, got #{params.inspect}" unless params.is_a?(Hash)
|
|
173
|
+
|
|
174
|
+
taken = params.keys.map(&:to_sym) & SORTABLE_DROP_PARAMS
|
|
175
|
+
unless taken.empty?
|
|
176
|
+
raise ArgumentError,
|
|
177
|
+
"reactive_sortable params: cannot set #{taken.first.inspect} — the client fills in " \
|
|
178
|
+
"#{SORTABLE_DROP_PARAMS.map(&:inspect).join(", ")} on drop"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
params.to_json
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def sortable_messages!(messages)
|
|
185
|
+
return SORTABLE_MESSAGES_JSON if messages.nil?
|
|
186
|
+
unless messages.is_a?(Hash)
|
|
187
|
+
raise ArgumentError,
|
|
188
|
+
"reactive_sortable messages: must be a Hash, got #{messages.inspect}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
unknown = messages.keys.map { it.to_s.to_sym } - SORTABLE_MESSAGES.keys
|
|
192
|
+
unless unknown.empty?
|
|
193
|
+
raise ArgumentError,
|
|
194
|
+
"reactive_sortable got an unknown messages: key #{unknown.first.inspect} — " \
|
|
195
|
+
"supported: #{SORTABLE_MESSAGES.keys.map(&:inspect).join(", ")}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
messages.each do |name, words|
|
|
199
|
+
next if words.is_a?(String)
|
|
200
|
+
|
|
201
|
+
raise ArgumentError, "reactive_sortable messages: #{name} must be a String, got #{words.inspect}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
SORTABLE_MESSAGES.merge(messages.transform_keys { it.to_s.to_sym }).to_json
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
SORTABLE_MESSAGES_JSON = SORTABLE_MESSAGES.to_json
|
|
208
|
+
private_constant :SORTABLE_MESSAGES_JSON, :SORTABLE_DROP_PARAMS
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
data/lib/phlex/reactive/defer.rb
CHANGED
|
@@ -112,6 +112,11 @@ module Phlex
|
|
|
112
112
|
"got #{placeholder.class}"
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
+
# The signed SSE URL a <pgbus-stream-source> subscribes to `key` with —
|
|
116
|
+
# minted off-view, as pgbus_stream_from would. Shared with presence
|
|
117
|
+
# (issue #358), whose roster subscribes to its stream the same way.
|
|
118
|
+
def stream_src(key) = signed_stream_src(key)
|
|
119
|
+
|
|
115
120
|
private
|
|
116
121
|
|
|
117
122
|
# The delivery directive: `<turbo-stream action="reactive:defer">`
|
|
@@ -20,10 +20,10 @@ module Phlex
|
|
|
20
20
|
# build script (scripts/build_client.js), the core's own table and the
|
|
21
21
|
# default entry name the same features —
|
|
22
22
|
# spec/phlex/engine_client_pin_spec.rb fails when they disagree.
|
|
23
|
-
CLIENT_FEATURES = %w[persist defer form bindings compute effects hints devtools].freeze
|
|
24
|
-
# (Gzipped, bun zlib level 9: the default file
|
|
25
|
-
# persist 3.
|
|
26
|
-
# 1.7, hints
|
|
23
|
+
CLIENT_FEATURES = %w[persist defer form bindings compute sortable presence effects hints devtools].freeze
|
|
24
|
+
# (Gzipped, bun zlib level 9, KB = 1,024 B: the default file 30.9 KB; the core 14.3 KB;
|
|
25
|
+
# persist 3.1, defer 2.9, form 1.1, bindings 6.5, compute 2.0, sortable
|
|
26
|
+
# 3.1, presence 1.3, effects 1.7, hints 0.9, devtools 1.5 KB. spec/javascript/bundle_budget.test.js
|
|
27
27
|
# is the source of truth and holds a ratchet on each.)
|
|
28
28
|
|
|
29
29
|
# Mount POST /reactive/actions -> Phlex::Reactive::ActionsController#create
|
|
@@ -202,6 +202,11 @@ module Phlex
|
|
|
202
202
|
Phlex::Reactive.freeze_param_types!
|
|
203
203
|
# Freeze the named-schema registry the same way (issue #184).
|
|
204
204
|
Phlex::Reactive.freeze_param_schemas!
|
|
205
|
+
|
|
206
|
+
# Presence (issue #358): register the presence stream pattern and the
|
|
207
|
+
# change hook with the transport. A no-op without a capable pgbus — an
|
|
208
|
+
# app on Action Cable boots exactly as before.
|
|
209
|
+
Phlex::Reactive::Presence.install!
|
|
205
210
|
end
|
|
206
211
|
end
|
|
207
212
|
end
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -304,6 +304,30 @@ module Phlex
|
|
|
304
304
|
append("dispatch", args.freeze)
|
|
305
305
|
end
|
|
306
306
|
|
|
307
|
+
# --- Presence (issue #358) ---
|
|
308
|
+
#
|
|
309
|
+
# presence(**state) — write this tab's presence state through the root's
|
|
310
|
+
# generated presence_set action (reactive_presence): only the declared
|
|
311
|
+
# keys reach the server, typed like action params. Always targets the
|
|
312
|
+
# ROOT, which must declare reactive_presence (the client warns and does
|
|
313
|
+
# nothing otherwise). ACTOR-ONLY like focus/submit: a broadcast that set
|
|
314
|
+
# every subscriber's selection would be hostile (BROADCAST_REFUSED_OPS).
|
|
315
|
+
# li(**on_client(:click, js.presence(selected: item.id))) { item.title }
|
|
316
|
+
def presence(**state)
|
|
317
|
+
raise ArgumentError, "#{self.class}: presence needs at least one state key (e.g. selected: 7)" if state.empty?
|
|
318
|
+
|
|
319
|
+
state.each do |name, value|
|
|
320
|
+
next if value.nil? || [String, Integer, TrueClass, FalseClass].any? { value.is_a?(it) }
|
|
321
|
+
next if value.is_a?(Float) && value.finite? # NaN and Infinity have no JSON form
|
|
322
|
+
|
|
323
|
+
raise ArgumentError,
|
|
324
|
+
"#{self.class}: presence state values must be scalar (String/Integer/finite Float/true/false/nil) — " \
|
|
325
|
+
"#{name.inspect} is #{value.class}"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
append("presence", { "to" => ROOT_SENTINEL, "state" => state.transform_keys(&:to_s).freeze }.freeze)
|
|
329
|
+
end
|
|
330
|
+
|
|
307
331
|
# The wire format: a JSON array of [op, args] pairs, applied in order.
|
|
308
332
|
def to_json(*)
|
|
309
333
|
@ops.to_json
|