phlex-reactive 0.9.5 → 0.11.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 +315 -0
- data/README.md +176 -122
- data/app/controllers/phlex/reactive/actions_controller.rb +41 -8
- data/app/javascript/phlex/reactive/confirm_predicate.js +52 -0
- data/app/javascript/phlex/reactive/confirm_predicate.min.js +4 -0
- data/app/javascript/phlex/reactive/confirm_predicate.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +475 -218
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/client_bindings.rb +57 -0
- data/lib/phlex/reactive/component/dsl.rb +158 -33
- data/lib/phlex/reactive/component/helpers.rb +478 -381
- data/lib/phlex/reactive/component/registry.rb +8 -1
- data/lib/phlex/reactive/component.rb +26 -12
- data/lib/phlex/reactive/defer.rb +1 -1
- data/lib/phlex/reactive/deferred_render_job.rb +2 -2
- data/lib/phlex/reactive/engine.rb +14 -0
- data/lib/phlex/reactive/js.rb +29 -15
- data/lib/phlex/reactive/reply.rb +105 -44
- data/lib/phlex/reactive/response.rb +139 -48
- data/lib/phlex/reactive/show_conditions.rb +249 -0
- data/lib/phlex/reactive/streamable.rb +244 -202
- data/lib/phlex/reactive/test_helpers.rb +10 -2
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +137 -12
- metadata +6 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# The blessed CLIENT-ONLY include (issue #180). A view that only shows/hides
|
|
6
|
+
# (reactive_show), filters (reactive_filter), computes client-side
|
|
7
|
+
# (reactive_compute), or runs zero-round-trip ops (on_client/js) — with NO
|
|
8
|
+
# server actions and NO signed token — includes this instead of the full
|
|
9
|
+
# Phlex::Reactive::Component.
|
|
10
|
+
#
|
|
11
|
+
# Why it exists: including the full Component pulls in Streamable, whose
|
|
12
|
+
# class/instance methods (replace/update/append/prepend/remove,
|
|
13
|
+
# to_stream_*) collide by NAME with an app's own Turbo-stream concern — so
|
|
14
|
+
# adopters cherry-picked an internal module and hand-rolled a token-less root
|
|
15
|
+
# (and defined a dummy #id "never read at render" just to keep
|
|
16
|
+
# phlex_reactive:doctor quiet). ClientBindings is that missing seam, blessed:
|
|
17
|
+
#
|
|
18
|
+
# class ExerciseFilter < ApplicationComponent
|
|
19
|
+
# include Phlex::Reactive::ClientBindings
|
|
20
|
+
#
|
|
21
|
+
# reactive_scope :form
|
|
22
|
+
# def reactive_values = { role: @role }
|
|
23
|
+
#
|
|
24
|
+
# def view_template
|
|
25
|
+
# div(**reactive_root) do # token-less: no #id required
|
|
26
|
+
# select(name: "form[role]") { role_options }
|
|
27
|
+
# div(**reactive_show(if: { role: "individual" })) { demographics }
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# It includes the DECLARATION DSL and the view Helpers, but NOT Streamable
|
|
33
|
+
# (nothing to clobber) or Identity (no token). So a ClientBindings class never
|
|
34
|
+
# enters Streamable.registered_classes and is invisible to
|
|
35
|
+
# phlex_reactive:doctor's #id check — no dummy #id, no nag.
|
|
36
|
+
#
|
|
37
|
+
# The client-only DSL macros (reactive_scope, reactive_compute) work; the
|
|
38
|
+
# SERVER-ACTION macros (action, reactive_record, reactive_state) RAISE at
|
|
39
|
+
# class-definition time — without Identity they could only silently no-op
|
|
40
|
+
# (sign nothing, dispatch nowhere), so the guard fails loudly (the guard lives
|
|
41
|
+
# in Component::DSL#require_server_actions!, keyed on Identity presence).
|
|
42
|
+
#
|
|
43
|
+
# Helpers is token-tolerant (issue #180): reactive_attrs emits a token only
|
|
44
|
+
# when reactive_token is available, and reactive_root uses #id only when
|
|
45
|
+
# defined — so the client-only view helpers work with no Identity/Streamable.
|
|
46
|
+
#
|
|
47
|
+
# The full Component includes ClientBindings too (ONE implementation of the
|
|
48
|
+
# client-only surface), then layers Streamable + Identity on top — so a
|
|
49
|
+
# token-bearing reactive_root is a SUPERSET, not a fork.
|
|
50
|
+
module ClientBindings
|
|
51
|
+
extend ActiveSupport::Concern
|
|
52
|
+
|
|
53
|
+
include Component::DSL
|
|
54
|
+
include Component::Helpers
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -29,10 +29,31 @@ module Phlex
|
|
|
29
29
|
MIRROR_ID_SELECTOR = /\A#[A-Za-z_][\w-]*\z/
|
|
30
30
|
|
|
31
31
|
class_methods do
|
|
32
|
+
# Guard the three SERVER-ACTION macros (issue #180). ClientBindings
|
|
33
|
+
# includes this DSL for its client-only macros (reactive_scope,
|
|
34
|
+
# reactive_compute) but does NOT include Identity/Streamable — so a
|
|
35
|
+
# signed token is never minted and no action endpoint dispatches to
|
|
36
|
+
# this class. Declaring action/reactive_record/reactive_state there
|
|
37
|
+
# would silently no-op (an action that can never fire, a record that
|
|
38
|
+
# signs nothing). Fail LOUDLY at class-definition time instead — the
|
|
39
|
+
# default-deny posture. Keyed on Identity presence: a full
|
|
40
|
+
# Phlex::Reactive::Component includes it (passes); a ClientBindings-only
|
|
41
|
+
# class does not (raises).
|
|
42
|
+
def require_server_actions!(macro)
|
|
43
|
+
return if include?(Phlex::Reactive::Component::Identity)
|
|
44
|
+
|
|
45
|
+
raise ArgumentError,
|
|
46
|
+
"#{self}: `#{macro}` needs the full Phlex::Reactive::Component — it signs a token / " \
|
|
47
|
+
"dispatches a server action, which Phlex::Reactive::ClientBindings deliberately omits " \
|
|
48
|
+
"(client-only: no token, no endpoint). Include Phlex::Reactive::Component for actions, " \
|
|
49
|
+
"or drop `#{macro}` and use the client-only helpers (reactive_show/filter/compute, on_client)."
|
|
50
|
+
end
|
|
51
|
+
|
|
32
52
|
# Declare the ActiveRecord (GlobalID-able) record this component is
|
|
33
53
|
# rebuilt from. The signed token carries its GlobalID; the server
|
|
34
54
|
# re-finds it on each action. State lives in the DB.
|
|
35
55
|
def reactive_record(name)
|
|
56
|
+
require_server_actions!(:reactive_record)
|
|
36
57
|
Registry.write_scalar(self, :record_key, name.to_sym)
|
|
37
58
|
end
|
|
38
59
|
|
|
@@ -44,9 +65,67 @@ module Phlex
|
|
|
44
65
|
Registry.resolve_scalar(self, :record_key, :reactive_record_key)
|
|
45
66
|
end
|
|
46
67
|
|
|
68
|
+
# Declare (with a name) OR read (bare) a form-field NAMESPACE for this
|
|
69
|
+
# component's bindings (issue #180). With `reactive_scope :form`,
|
|
70
|
+
# reactive_show / reactive_values use bare symbols (`:director`) and the
|
|
71
|
+
# client resolves the owned field as `[name="form[director]"]`; the root
|
|
72
|
+
# emits `data-reactive-scope="form"`. Bare `reactive_scope` reads the
|
|
73
|
+
# nearest declared scope up the ancestry (nil when none) — the
|
|
74
|
+
# dual-purpose form matching reactive_compute's getter/setter. A
|
|
75
|
+
# per-binding raw string field name still passes through unscoped.
|
|
76
|
+
def reactive_scope(name = nil)
|
|
77
|
+
return Registry.resolve_scalar(self, :scope, :reactive_scope) if name.nil?
|
|
78
|
+
|
|
79
|
+
scope = name.to_sym
|
|
80
|
+
# Issue #184: catch an action already declared with a schema nested under
|
|
81
|
+
# THIS scope key (the action-first declaration order) — the endpoint
|
|
82
|
+
# unwraps ONE scope level, so a schema pre-nested under it would be dead.
|
|
83
|
+
reactive_actions.each_value { assert_no_scope_double_nesting!(scope, it.params, it.name) }
|
|
84
|
+
Registry.write_scalar(self, :scope, scope)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Raise a guided ArgumentError when a param schema is ALREADY nested under
|
|
88
|
+
# the scope key (issue #184): the endpoint peels one scope level, so
|
|
89
|
+
# params: { <scope>: { … } } would double-unwrap to nothing. Shared by both
|
|
90
|
+
# macros so neither declaration order can dodge the guard. A nested key that
|
|
91
|
+
# is NOT the scope (a real nested_attributes param) is fine.
|
|
92
|
+
def assert_no_scope_double_nesting!(scope, params, action_name)
|
|
93
|
+
return unless params.is_a?(Hash) && params[scope].is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
raise ArgumentError,
|
|
96
|
+
"#{self}: action #{action_name.inspect} nests its schema under the reactive_scope " \
|
|
97
|
+
"key #{scope.inspect} (params: { #{scope}: { … } }). The endpoint unwraps one scope " \
|
|
98
|
+
"level already — declare the schema FLAT: params: { #{params[scope].keys.first || :field}: … }."
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Issue #184: ONE dirty-tracking declaration, class-level. Replaces
|
|
102
|
+
# reactive_root(track_dirty:, warn_unsaved:) + reactive_field(dirty:).
|
|
103
|
+
#
|
|
104
|
+
# reactive_dirty # track every field via the root
|
|
105
|
+
# reactive_dirty warn_unsaved: true # + warn before navigating away
|
|
106
|
+
# reactive_dirty only: %i[title] # track only these fields (per-field)
|
|
107
|
+
#
|
|
108
|
+
# Stored as a frozen config the render helpers read; the emitted DOM is
|
|
109
|
+
# byte-identical to the old kwargs, so the client is unchanged. `only:`
|
|
110
|
+
# switches to per-field descriptors (no root-level input delegation);
|
|
111
|
+
# otherwise the root delegates for the whole subtree.
|
|
112
|
+
def reactive_dirty(warn_unsaved: false, only: nil)
|
|
113
|
+
Registry.write_scalar(self, :dirty, {
|
|
114
|
+
warn_unsaved: warn_unsaved ? true : false,
|
|
115
|
+
only: only && Array(only).map(&:to_sym).freeze
|
|
116
|
+
}.freeze)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The resolved dirty config (or nil when reactive_dirty was never
|
|
120
|
+
# declared), inherited through the Registry like every other scalar.
|
|
121
|
+
def reactive_dirty_config
|
|
122
|
+
Registry.resolve_scalar(self, :dirty, :reactive_dirty_config)
|
|
123
|
+
end
|
|
124
|
+
|
|
47
125
|
# Opt into signed STATE for record-less components only.
|
|
48
126
|
# reactive_state :count, :open
|
|
49
127
|
def reactive_state(*names)
|
|
128
|
+
require_server_actions!(:reactive_state)
|
|
50
129
|
Registry.append(self, :state_keys, names.map(&:to_sym))
|
|
51
130
|
end
|
|
52
131
|
|
|
@@ -112,6 +191,14 @@ module Phlex
|
|
|
112
191
|
# Array params accept BOTH a JSON array and a Rails-style index hash
|
|
113
192
|
# ({ "0" => ..., "1" => ... }), so a fields_for collection works either way.
|
|
114
193
|
def action(name, params: {})
|
|
194
|
+
require_server_actions!(:action)
|
|
195
|
+
# Issue #184: params: :symbol resolves a registered named schema.
|
|
196
|
+
params = Phlex::Reactive.param_schema(params) if params.is_a?(Symbol)
|
|
197
|
+
# If a scope is already declared, reject a schema nested under the scope
|
|
198
|
+
# key here (the scope-first declaration order). The action-first order is
|
|
199
|
+
# caught in reactive_scope.
|
|
200
|
+
scope = reactive_scope
|
|
201
|
+
assert_no_scope_double_nesting!(scope, params, name.to_sym) if scope
|
|
115
202
|
Registry.write_entry(
|
|
116
203
|
self, :actions, name.to_sym,
|
|
117
204
|
Action.new(name: name.to_sym, params: params, schema: Phlex::Reactive::ParamSchema.compile(params))
|
|
@@ -127,12 +214,17 @@ module Phlex
|
|
|
127
214
|
Registry.resolve_hash(self, :actions, :reactive_actions)
|
|
128
215
|
end
|
|
129
216
|
|
|
217
|
+
# Issue #186: the fetch-one readers are removed — the plural
|
|
218
|
+
# reactive_actions hash IS the fetch-one. Guided errors name the rewrite.
|
|
130
219
|
def reactive_action(name)
|
|
131
|
-
|
|
220
|
+
raise NoMethodError,
|
|
221
|
+
"reactive_action(#{name.inspect}) was removed in issue #186 — use reactive_actions[#{name.inspect}]"
|
|
132
222
|
end
|
|
133
223
|
|
|
134
224
|
def reactive_action?(name)
|
|
135
|
-
|
|
225
|
+
raise NoMethodError,
|
|
226
|
+
"reactive_action?(#{name.inspect}) was removed in issue #186 — " \
|
|
227
|
+
"use reactive_actions.key?(#{name.inspect})"
|
|
136
228
|
end
|
|
137
229
|
|
|
138
230
|
# Opt out of the default-ON verify_authorized guard (issue #168).
|
|
@@ -181,9 +273,10 @@ module Phlex
|
|
|
181
273
|
# empty: ItemsEmptyComponent, # optional empty-state component
|
|
182
274
|
# size: -> { @record.items.size } # resolves the live size
|
|
183
275
|
#
|
|
184
|
-
# An action then governs the reply with one call
|
|
185
|
-
#
|
|
186
|
-
# reply.
|
|
276
|
+
# An action then governs the reply with one call (the collection is
|
|
277
|
+
# named by the to:/from: keyword, issue #182):
|
|
278
|
+
# reply.append(item, to: :items) # row append + count + clear empty-state
|
|
279
|
+
# reply.remove(id, from: :items) # row remove + count + restore empty-state
|
|
187
280
|
#
|
|
188
281
|
# count/empty/size are optional: a list with just rows omits them and the
|
|
189
282
|
# corresponding stream isn't emitted. See Phlex::Reactive::Reply and the
|
|
@@ -199,12 +292,17 @@ module Phlex
|
|
|
199
292
|
Registry.resolve_hash(self, :collections, :reactive_collections)
|
|
200
293
|
end
|
|
201
294
|
|
|
295
|
+
# Issue #186: use the plural reactive_collections hash directly.
|
|
202
296
|
def reactive_collection_def(name)
|
|
203
|
-
|
|
297
|
+
raise NoMethodError,
|
|
298
|
+
"reactive_collection_def(#{name.inspect}) was removed in issue #186 — " \
|
|
299
|
+
"use reactive_collections[#{name.inspect}]"
|
|
204
300
|
end
|
|
205
301
|
|
|
206
302
|
def reactive_collection?(name)
|
|
207
|
-
|
|
303
|
+
raise NoMethodError,
|
|
304
|
+
"reactive_collection?(#{name.inspect}) was removed in issue #186 — " \
|
|
305
|
+
"use reactive_collections.key?(#{name.inspect})"
|
|
208
306
|
end
|
|
209
307
|
|
|
210
308
|
# Declare a client-side computation, OR (called with just a name) read
|
|
@@ -262,17 +360,13 @@ module Phlex
|
|
|
262
360
|
# the client interpreter re-checks the same shape).
|
|
263
361
|
def reactive_compute(name, inputs: nil, outputs: nil, reducer: nil, mirror: nil)
|
|
264
362
|
if inputs.nil? && outputs.nil?
|
|
265
|
-
#
|
|
266
|
-
#
|
|
267
|
-
#
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
"alongside inputs:/outputs:."
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
return reactive_compute_def(name)
|
|
363
|
+
# Issue #186: the bare getter form is removed — reactive_compute is
|
|
364
|
+
# the SETTER now; read one compute via the plural hash. (Raising here
|
|
365
|
+
# keeps the getter/setter overload from silently misfiring.)
|
|
366
|
+
raise ArgumentError,
|
|
367
|
+
"reactive_compute(#{name.inspect}) getter was removed in issue #186 — " \
|
|
368
|
+
"use reactive_computes[#{name.inspect}]. reactive_compute is the declarative " \
|
|
369
|
+
"SETTER now: reactive_compute(:name, inputs:, outputs:)."
|
|
276
370
|
end
|
|
277
371
|
|
|
278
372
|
input_names, input_types = normalize_compute_inputs(inputs)
|
|
@@ -294,28 +388,59 @@ module Phlex
|
|
|
294
388
|
# reactive_collection_def (issue #115). The bare reactive_compute(name)
|
|
295
389
|
# getter above is a PERMANENT documented alias (it shipped in the same
|
|
296
390
|
# minor series, #73); both stay, no deprecation.
|
|
391
|
+
# Issue #186: use the plural reactive_computes hash directly.
|
|
297
392
|
def reactive_compute_def(name)
|
|
298
|
-
|
|
393
|
+
raise NoMethodError,
|
|
394
|
+
"reactive_compute_def(#{name.inspect}) was removed in issue #186 — " \
|
|
395
|
+
"use reactive_computes[#{name.inspect}]"
|
|
299
396
|
end
|
|
300
397
|
|
|
301
398
|
def reactive_compute?(name)
|
|
302
|
-
|
|
399
|
+
raise NoMethodError,
|
|
400
|
+
"reactive_compute?(#{name.inspect}) was removed in issue #186 — " \
|
|
401
|
+
"use reactive_computes.key?(#{name.inspect})"
|
|
303
402
|
end
|
|
304
403
|
|
|
305
|
-
# Split the `inputs:` argument into [ordered names, types-or-nil]
|
|
306
|
-
#
|
|
307
|
-
#
|
|
308
|
-
#
|
|
309
|
-
#
|
|
310
|
-
#
|
|
404
|
+
# Split the `inputs:` argument into [ordered names, types-or-nil].
|
|
405
|
+
# THREE shapes, all degenerate cases of the issue-#183 PERMIT form
|
|
406
|
+
# (bare symbols default to :number; a trailing Hash types the exceptions):
|
|
407
|
+
#
|
|
408
|
+
# * a pure Hash ({ title: :string, qty: :number }) — the issue-#104 typed
|
|
409
|
+
# form. Ordered keys + a name→type map.
|
|
410
|
+
# * a permit Array with a trailing Hash ([:qty, { title: :string }]) —
|
|
411
|
+
# bare symbols typed :number, the Hash keys typed as declared. This is
|
|
412
|
+
# the one form the issue-#183 docs show.
|
|
413
|
+
# * a bare Array/symbol ([:price, :qty]) — the issue-#73 UNTYPED form.
|
|
414
|
+
# nil types, so the array-form wire stays byte-identical and the client
|
|
415
|
+
# keeps its numeric coercion.
|
|
416
|
+
#
|
|
417
|
+
# Named after the shape it normalizes.
|
|
311
418
|
def normalize_compute_inputs(inputs)
|
|
312
|
-
if inputs.is_a?(Hash)
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
419
|
+
return normalize_typed_compute_inputs(inputs) if inputs.is_a?(Hash)
|
|
420
|
+
|
|
421
|
+
# dup before popping: Array(inputs) returns the SAME object for an array
|
|
422
|
+
# arg, so an unguarded pop would mutate (or FrozenError on) a shared or
|
|
423
|
+
# frozen inputs array the caller still holds.
|
|
424
|
+
list = Array(inputs).dup
|
|
425
|
+
trailing = list.last.is_a?(Hash) ? list.pop : nil
|
|
426
|
+
names = list.map(&:to_sym)
|
|
427
|
+
|
|
428
|
+
# No trailing type Hash → the untyped array form (nil types).
|
|
429
|
+
return [names, nil] if trailing.nil?
|
|
430
|
+
|
|
431
|
+
# Permit form: bare names default to :number; the trailing Hash types
|
|
432
|
+
# the exceptions. Declaration order is bare names, then the Hash keys.
|
|
433
|
+
typed = trailing.transform_keys(&:to_sym).transform_values(&:to_sym)
|
|
434
|
+
ordered = names + typed.keys
|
|
435
|
+
types = names.to_h { [it, :number] }.merge(typed)
|
|
436
|
+
[ordered, types]
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# A pure Hash of name => type (issue #104): ordered keys + the type map.
|
|
440
|
+
def normalize_typed_compute_inputs(inputs)
|
|
441
|
+
names = inputs.keys.map(&:to_sym)
|
|
442
|
+
types = inputs.transform_keys(&:to_sym).transform_values(&:to_sym)
|
|
443
|
+
[names, types]
|
|
319
444
|
end
|
|
320
445
|
|
|
321
446
|
# Normalize `mirror:` to { name => [id selectors] } (nil passes
|