phlex-reactive 0.13.1 → 0.13.3
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 +153 -0
- data/README.md +88 -13
- data/app/controllers/phlex/reactive/actions_controller.rb +72 -3
- data/app/javascript/phlex/reactive/compute.js +9 -1
- data/app/javascript/phlex/reactive/compute.min.js.map +2 -2
- data/app/javascript/phlex/reactive/confirm_predicate.js +7 -1
- data/app/javascript/phlex/reactive/confirm_predicate.min.js.map +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.js +417 -51
- 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/component/dsl.rb +7 -0
- data/lib/phlex/reactive/component/helpers.rb +4 -3
- data/lib/phlex/reactive/component.rb +2 -1
- data/lib/phlex/reactive/param_schema.rb +16 -0
- data/lib/phlex/reactive/settles.rb +50 -13
- data/lib/phlex/reactive/test_helpers.rb +10 -3
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
|
@@ -389,6 +389,13 @@ module Phlex
|
|
|
389
389
|
# inputs: { title: :string, qty: :number },
|
|
390
390
|
# outputs: %i[title_preview char_count]
|
|
391
391
|
#
|
|
392
|
+
# A CHECKBOX contributes its checked state, never its constant .value
|
|
393
|
+
# (issue #262): 1/0 as a :number, "true"/"false" as a :string, and a
|
|
394
|
+
# real boolean as a :boolean. It wins over the hidden companion Rails
|
|
395
|
+
# renders before it. A radio group contributes its checked radio's value.
|
|
396
|
+
#
|
|
397
|
+
# reactive_compute :total, inputs: [:price, { gift_wrap: :boolean }], outputs: %i[total]
|
|
398
|
+
#
|
|
392
399
|
# An output with no matching form field writes to its reactive_text(:name)
|
|
393
400
|
# node (textContent); a declared input also mirrors into its own text node
|
|
394
401
|
# with no reducer. The array form's wire stays byte-identical.
|
|
@@ -449,7 +449,7 @@ module Phlex
|
|
|
449
449
|
# the baseline is the DOM's own `defaultValue`/`defaultChecked`/
|
|
450
450
|
# `defaultSelected` from the last server render (dirty = current ≠ default).
|
|
451
451
|
# The descriptor deep-merges via mix, so a caller's own data-action is
|
|
452
|
-
# token-joined, not clobbered (
|
|
452
|
+
# token-joined, not clobbered (AGENTS.md Never-Do #8).
|
|
453
453
|
def reactive_field(param, **attrs)
|
|
454
454
|
# Issue #184: the removed dirty: kwarg lands in **attrs — catch it and
|
|
455
455
|
# print the reactive_dirty rewrite.
|
|
@@ -1232,8 +1232,9 @@ module Phlex
|
|
|
1232
1232
|
# The inputs param wire (issue #104). Untyped (array form) → a JSON ARRAY of
|
|
1233
1233
|
# names, byte-identical to the shipped wire so the client keeps its numeric
|
|
1234
1234
|
# coercion. Typed (hash form) → a JSON OBJECT of name→type
|
|
1235
|
-
# ({"title":"string","qty":"number"}) so the client reads a :string raw
|
|
1236
|
-
# coerces a :number through Number
|
|
1235
|
+
# ({"title":"string","qty":"number"}) so the client reads a :string raw,
|
|
1236
|
+
# coerces a :number through Number and reads a :boolean as a checkbox's
|
|
1237
|
+
# checked state (issue #262).
|
|
1237
1238
|
def compute_inputs_param(definition)
|
|
1238
1239
|
types = definition.input_types
|
|
1239
1240
|
return definition.inputs.map(&:to_s).to_json if types.nil?
|
|
@@ -102,7 +102,8 @@ module Phlex
|
|
|
102
102
|
# `input_types` (issue #104) is nil for the ARRAY input form (untyped ⇒ the
|
|
103
103
|
# client coerces every input through Number, the shipped behavior) and a
|
|
104
104
|
# { name => type } hash for the typed HASH form (:string reads the field
|
|
105
|
-
# value raw, :number coerces
|
|
105
|
+
# value raw, :number coerces, :boolean reads a checkbox's checked state —
|
|
106
|
+
# issue #262). `inputs` stays the ordered name list either
|
|
106
107
|
# way, so iteration order is preserved and the array-form wire is unchanged.
|
|
107
108
|
#
|
|
108
109
|
# `mirror` (issue #159) is nil when undeclared, else a { name => [id
|
|
@@ -174,6 +174,22 @@ module Phlex
|
|
|
174
174
|
coerced.equal?(DROP) ? {} : coerced
|
|
175
175
|
end
|
|
176
176
|
|
|
177
|
+
# The key this DECLARATION holds for `name` if it declares it as an ARRAY
|
|
178
|
+
# param, else nil. Reads the declaration, never the incoming params, so the
|
|
179
|
+
# endpoint's empty-group announcement (issue #258) can only ever fill
|
|
180
|
+
# something the action asked for.
|
|
181
|
+
#
|
|
182
|
+
# A key that is neither a String nor a Symbol stays unresolved on purpose:
|
|
183
|
+
# `compile` accepts `{ 0 => [:string] }`, but `coerce_hash` raises on
|
|
184
|
+
# `0.to_sym` the moment that key is PRESENT, and the announcement is what
|
|
185
|
+
# would make it present. Resolving it would turn a request that answers 200
|
|
186
|
+
# and fills nothing into a 500.
|
|
187
|
+
def array_param(name)
|
|
188
|
+
name = name.to_s
|
|
189
|
+
key, type = @schema.find { |k, _| (k.is_a?(String) || k.is_a?(Symbol)) && k.to_s == name }
|
|
190
|
+
key if type.is_a?(Array)
|
|
191
|
+
end
|
|
192
|
+
|
|
177
193
|
private
|
|
178
194
|
|
|
179
195
|
# Coerce a value against a declared type. Arrays accept both a real JSON
|
|
@@ -27,15 +27,19 @@ module Phlex
|
|
|
27
27
|
# ## The handle rides ActiveJob metadata
|
|
28
28
|
#
|
|
29
29
|
# `reply.pending` installs a Pending::Handle in a thread-local and runs the
|
|
30
|
-
# caller's enqueue inside it; #
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
30
|
+
# caller's enqueue inside it; #initialize below captures it onto the job
|
|
31
|
+
# instance, #serialize copies it into the job's metadata, #deserialize
|
|
32
|
+
# restores it. So `perform`'s ARITY IS UNTOUCHED and every OTHER caller of
|
|
33
|
+
# the same job — a nightly sweep, a webhook — builds it with no handle, in
|
|
34
|
+
# which case `reactive_settle` is a NO-OP that returns nil. That is
|
|
35
|
+
# load-bearing: these jobs almost always have non-UI callers.
|
|
35
36
|
#
|
|
36
|
-
# (`perform_now` does
|
|
37
|
-
#
|
|
38
|
-
#
|
|
37
|
+
# (A `perform_now` INSIDE the enqueue block does carry the handle, since the
|
|
38
|
+
# instance is built there — so it settles synchronously. That is the right
|
|
39
|
+
# reading: `reply.pending` already marked the targets, and something has to
|
|
40
|
+
# resolve the shimmer. The settle's durable message is replayed from
|
|
41
|
+
# since-id 0 when the client opens the subscription, so arriving before it
|
|
42
|
+
# exists is safe.)
|
|
39
43
|
#
|
|
40
44
|
# ## A rolled-back action
|
|
41
45
|
#
|
|
@@ -57,12 +61,45 @@ module Phlex
|
|
|
57
61
|
# shared namespace with every other gem in the app.
|
|
58
62
|
SETTLE_METADATA_KEY = "phlex_reactive_settle"
|
|
59
63
|
|
|
60
|
-
# Capture the in-flight settle handle at
|
|
61
|
-
#
|
|
62
|
-
# is
|
|
63
|
-
#
|
|
64
|
+
# Capture the in-flight settle handle at INSTANTIATION (issue #254).
|
|
65
|
+
#
|
|
66
|
+
# `serialize` is the seam pgbus's own ActiveJob::CurrentAttributes
|
|
67
|
+
# integration uses, and it looked like the enqueue-time hook — but under
|
|
68
|
+
# Rails' `enqueue_after_transaction_commit = true` (the 7.2+ recommended
|
|
69
|
+
# setting) `job.enqueue` is deferred to
|
|
70
|
+
# ActiveRecord.after_all_transactions_commit, and the endpoint runs every
|
|
71
|
+
# action inside a transaction. So the deferral — and with it `serialize` —
|
|
72
|
+
# always fires AFTER reply.pending's `with_handle` block has exited, with
|
|
73
|
+
# an empty thread-local: no metadata key, a no-op `reactive_settle`, and a
|
|
74
|
+
# row left shimmering until someone reloads.
|
|
75
|
+
#
|
|
76
|
+
# `new` is the one moment guaranteed to be inside the block:
|
|
77
|
+
# `perform_later` → `job_or_instantiate` → `new` is synchronous, deferral
|
|
78
|
+
# or not. Pending#each_with_narrowed_handle narrows the thread-local per
|
|
79
|
+
# record BEFORE `perform_later`, so the `job:`/`args:` attribution
|
|
80
|
+
# contract is preserved.
|
|
81
|
+
def initialize(...)
|
|
82
|
+
super
|
|
83
|
+
@reactive_settle_handle ||= Phlex::Reactive::Pending.current_handle
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The same capture at ENQUEUE, for an instance built BEFORE the block and
|
|
87
|
+
# enqueued inside it (`job = MyJob.new(...)` … `reply.pending { job.enqueue }`).
|
|
88
|
+
# `enqueue` runs synchronously inside the block — it is the deferral it
|
|
89
|
+
# REGISTERS that runs later — so this is the last moment the thread-local
|
|
90
|
+
# is visible. `||=` never overwrites: a retry's `retry_job` re-enqueues a
|
|
91
|
+
# DESERIALIZED instance, which must keep the handle it came back with.
|
|
92
|
+
def enqueue(...)
|
|
93
|
+
@reactive_settle_handle ||= Phlex::Reactive::Pending.current_handle
|
|
94
|
+
super
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Prefer the captured handle; the thread-local fallback covers an instance
|
|
98
|
+
# serialized inside the block without going through either hook. A retry
|
|
99
|
+
# re-enqueue re-serializes the SAME instance, which keeps its handle — the
|
|
100
|
+
# right reading: the pending UI is still waiting on this work.
|
|
64
101
|
def serialize
|
|
65
|
-
handle = Phlex::Reactive::Pending.current_handle
|
|
102
|
+
handle = @reactive_settle_handle || Phlex::Reactive::Pending.current_handle
|
|
66
103
|
return super unless handle
|
|
67
104
|
|
|
68
105
|
super.merge(SETTLE_METADATA_KEY => handle.to_h_wire)
|
|
@@ -64,10 +64,17 @@ module Phlex
|
|
|
64
64
|
# `:file` param is present, issue #34): token + act flat, params bracketed.
|
|
65
65
|
# No JSON Content-Type — Rails' test `post` builds the multipart body from
|
|
66
66
|
# the nested Hash (files ride as UploadedFile values).
|
|
67
|
-
|
|
67
|
+
#
|
|
68
|
+
# `empty_groups:` names the `[]` groups the client cleared (issue #258). A
|
|
69
|
+
# form body cannot carry an empty array, so the client leaves those keys
|
|
70
|
+
# out and announces them in a field of its own; passing the names here is
|
|
71
|
+
# how a spec reproduces that request. Omit it and the body is exactly what
|
|
72
|
+
# it was before the field existed.
|
|
73
|
+
def post_reactive_multipart(component_or_class, act, params: {}, payload: {}, empty_groups: [])
|
|
68
74
|
token = reactive_action_token(component_or_class, payload)
|
|
69
|
-
|
|
70
|
-
|
|
75
|
+
body = { token:, act:, params: }
|
|
76
|
+
body[:empty_groups] = empty_groups if empty_groups.present?
|
|
77
|
+
post Phlex::Reactive.action_path, params: body,
|
|
71
78
|
headers: { "Accept" => "text/vnd.turbo-stream.html" }
|
|
72
79
|
end
|
|
73
80
|
|