phlex-reactive 0.4.7 → 0.9.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 +970 -0
- data/README.md +1134 -49
- data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
- data/app/javascript/phlex/reactive/compute.js +93 -0
- data/app/javascript/phlex/reactive/compute.min.js +4 -0
- data/app/javascript/phlex/reactive/compute.min.js.map +10 -0
- data/app/javascript/phlex/reactive/confirm.min.js +4 -0
- data/app/javascript/phlex/reactive/confirm.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +1591 -56
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -0
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -0
- data/lib/generators/phlex/reactive/component/USAGE +2 -1
- data/lib/generators/phlex/reactive/component/templates/component.rb.erb +1 -2
- data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +33 -2
- data/lib/generators/phlex/reactive/install/install_generator.rb +9 -3
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +37 -0
- data/lib/phlex/reactive/component/dsl.rb +249 -0
- data/lib/phlex/reactive/component/helpers.rb +595 -0
- data/lib/phlex/reactive/component/identity.rb +92 -0
- data/lib/phlex/reactive/component/registry.rb +200 -0
- data/lib/phlex/reactive/component.rb +37 -348
- data/lib/phlex/reactive/doctor.rb +333 -0
- data/lib/phlex/reactive/engine.rb +36 -7
- data/lib/phlex/reactive/js.rb +222 -0
- data/lib/phlex/reactive/log_subscriber.rb +64 -0
- data/lib/phlex/reactive/param_schema.rb +390 -0
- data/lib/phlex/reactive/reply.rb +5 -3
- data/lib/phlex/reactive/response.rb +156 -16
- data/lib/phlex/reactive/stream.rb +98 -0
- data/lib/phlex/reactive/streamable.rb +307 -43
- data/lib/phlex/reactive/test_helpers/matchers.rb +112 -0
- data/lib/phlex/reactive/test_helpers.rb +308 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +329 -14
- data/lib/tasks/phlex_reactive.rake +14 -0
- metadata +20 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# Public test helpers for downstream apps (issue #110). Livewire ships
|
|
6
|
+
# `Livewire::test(...)`; this is the phlex-reactive equivalent — a public
|
|
7
|
+
# surface so an app never reaches for the PRIVATE `component.send(:reactive_token)`
|
|
8
|
+
# or hand-rolls the POST headers the docs used to teach.
|
|
9
|
+
#
|
|
10
|
+
# Mix it in from your rails_helper:
|
|
11
|
+
#
|
|
12
|
+
# RSpec.configure do |c|
|
|
13
|
+
# c.include Phlex::Reactive::TestHelpers, type: :request # HTTP helpers
|
|
14
|
+
# c.include Phlex::Reactive::TestHelpers # + the no-HTTP driver
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# The HTTP helpers (post_reactive_action/post_reactive_multipart) need Rails'
|
|
18
|
+
# integration `post`, so they belong in `type: :request` examples. The no-HTTP
|
|
19
|
+
# driver (run_reactive) and token minting work anywhere.
|
|
20
|
+
#
|
|
21
|
+
# The driver goes THROUGH the endpoint's security contract on purpose —
|
|
22
|
+
# default-deny, signed identity round-trip (record re-find), schema coercion,
|
|
23
|
+
# the same transaction wrapper. A helper that skipped it would teach users to
|
|
24
|
+
# test a component that would fail at the real endpoint.
|
|
25
|
+
#
|
|
26
|
+
# NB: `verbose_errors` defaults ON in the test env (Rails.env.local? — issue
|
|
27
|
+
# #82). It only changes an endpoint FAILURE body (never a status), so it does
|
|
28
|
+
# not affect these helpers' happy path; a downstream app asserting an empty
|
|
29
|
+
# failure body may want `Phlex::Reactive.verbose_errors = false` in its setup.
|
|
30
|
+
module TestHelpers
|
|
31
|
+
# Raised by run_reactive when the action is not declared with `action
|
|
32
|
+
# :name`. The endpoint answers this with a 403 (default-deny); the driver
|
|
33
|
+
# raises so a unit test asserts the contract directly. A dedicated class
|
|
34
|
+
# (not a bare ArgumentError) so `raise_error(UndeclaredReactiveAction)` is
|
|
35
|
+
# unambiguous.
|
|
36
|
+
class UndeclaredReactiveAction < Phlex::Reactive::Error; end
|
|
37
|
+
|
|
38
|
+
# Mint an identity token exactly as a component would.
|
|
39
|
+
# * CLASS form — the already-public sign path: Phlex::Reactive.sign(
|
|
40
|
+
# payload.merge("c" => klass.name)). Pass the identity pieces yourself
|
|
41
|
+
# ("gid" => record.to_gid.to_s, "s" => {...}) when you need them.
|
|
42
|
+
# * INSTANCE form — wraps the component's PRIVATE #reactive_token, so the
|
|
43
|
+
# token carries the instance's live record/state with no hand-assembly.
|
|
44
|
+
def reactive_token_for(component_or_class, payload = {})
|
|
45
|
+
if component_or_class.is_a?(::Class)
|
|
46
|
+
Phlex::Reactive.sign(payload.merge("c" => component_or_class.name))
|
|
47
|
+
else
|
|
48
|
+
component_or_class.send(:reactive_token)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# POST a reactive action as the client's JSON body (token + act + params) to
|
|
53
|
+
# Phlex::Reactive.action_path — NEVER a hardcoded "/reactive/actions", so a
|
|
54
|
+
# remounted path (the gem warns about shadowed paths) is honored. Accepts a
|
|
55
|
+
# component INSTANCE or a CLASS + explicit `payload:` (the identity pieces).
|
|
56
|
+
def post_reactive_action(component_or_class, act, params: {}, payload: {})
|
|
57
|
+
token = reactive_action_token(component_or_class, payload)
|
|
58
|
+
post Phlex::Reactive.action_path,
|
|
59
|
+
params: { token:, act:, params: }.to_json,
|
|
60
|
+
headers: { "Content-Type" => "application/json", "Accept" => "text/vnd.turbo-stream.html" }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# POST a reactive action as multipart FormData (the client's encoding when a
|
|
64
|
+
# `:file` param is present, issue #34): token + act flat, params bracketed.
|
|
65
|
+
# No JSON Content-Type — Rails' test `post` builds the multipart body from
|
|
66
|
+
# the nested Hash (files ride as UploadedFile values).
|
|
67
|
+
def post_reactive_multipart(component_or_class, act, params: {}, payload: {})
|
|
68
|
+
token = reactive_action_token(component_or_class, payload)
|
|
69
|
+
post Phlex::Reactive.action_path,
|
|
70
|
+
params: { token:, act:, params: },
|
|
71
|
+
headers: { "Accept" => "text/vnd.turbo-stream.html" }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The no-HTTP unit driver. Runs `action` on `component` through the SAME
|
|
75
|
+
# security contract the endpoint enforces, and returns a Result:
|
|
76
|
+
#
|
|
77
|
+
# 1. default-deny — an undeclared action raises UndeclaredReactiveAction.
|
|
78
|
+
# 2. identity round-trip — signs the instance's token, verifies it, and
|
|
79
|
+
# rebuilds the component via from_identity (re-finding a record-backed
|
|
80
|
+
# component's row; a stale gid raises ActiveRecord::RecordNotFound, the
|
|
81
|
+
# endpoint's 404 equivalent). So the action runs against the rebuilt
|
|
82
|
+
# instance, exactly as it would after a real POST.
|
|
83
|
+
# 3. coercion — params pass through the action's compiled ParamSchema
|
|
84
|
+
# (issue #109); undeclared keys are dropped, declared ones cast.
|
|
85
|
+
# 4. transaction — run inside the endpoint's transaction wrapper so
|
|
86
|
+
# after_commit broadcasts behave.
|
|
87
|
+
# 5. authorization — a registered authorization error RAISES (the endpoint
|
|
88
|
+
# maps it to 403; a unit test asserts the real exception).
|
|
89
|
+
def run_reactive(component, action, **params)
|
|
90
|
+
klass = component.class
|
|
91
|
+
action_def = klass.reactive_action(action) || raise_undeclared(klass, action)
|
|
92
|
+
|
|
93
|
+
rebuilt = rebuild_from_identity(component, klass)
|
|
94
|
+
coerced = action_def.schema.coerce(stringify_params(params))
|
|
95
|
+
|
|
96
|
+
returned = run_reactive_action(rebuilt, action_def, coerced)
|
|
97
|
+
Result.new(returned:, component: rebuilt)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# A component instance mints its own token; a class needs the caller's
|
|
103
|
+
# explicit identity payload merged under "c".
|
|
104
|
+
def reactive_action_token(component_or_class, payload)
|
|
105
|
+
if component_or_class.is_a?(::Class)
|
|
106
|
+
reactive_token_for(component_or_class, payload)
|
|
107
|
+
else
|
|
108
|
+
reactive_token_for(component_or_class)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def raise_undeclared(klass, action)
|
|
113
|
+
declared = klass.reactive_actions.keys.join(", ")
|
|
114
|
+
raise UndeclaredReactiveAction,
|
|
115
|
+
"action :#{action} is not declared on #{klass.name} — declared actions: #{declared}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Round-trip the identity: sign the instance's identity, verify it, rebuild
|
|
119
|
+
# via from_identity — so the driver re-finds a record-backed component's row
|
|
120
|
+
# exactly like the endpoint (and a stale gid raises RecordNotFound).
|
|
121
|
+
#
|
|
122
|
+
# The token captures identity AS OF RENDER TIME: a record-backed component
|
|
123
|
+
# holding a row that was deleted AFTER the page rendered still carries that
|
|
124
|
+
# row's gid — the real stale-token case (the page showed it, then it was
|
|
125
|
+
# destroyed, then the action fired). So we sign the gid from the record's id
|
|
126
|
+
# even when it's already destroyed, then from_identity's GlobalID lookup
|
|
127
|
+
# returns nil and raises RecordNotFound — the endpoint's 404 equivalent.
|
|
128
|
+
# A genuinely NEW (never-persisted) record has no id → no gid, matching
|
|
129
|
+
# reactive_token's own draft contract.
|
|
130
|
+
def rebuild_from_identity(component, klass)
|
|
131
|
+
payload = Phlex::Reactive.verify(reactive_token_for(component))
|
|
132
|
+
klass.from_identity(inject_destroyed_gid(payload, component, klass))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Reproduce the stale-token case. #reactive_token omits the gid for a
|
|
136
|
+
# non-persisted record — correct for a live draft, but a DESTROYED record is
|
|
137
|
+
# also non-persisted, and there the page rendered a real gid (while the row
|
|
138
|
+
# lived) that only went stale on deletion. So when the component holds a
|
|
139
|
+
# record that was persisted (has an id) but is now destroyed, inject its gid
|
|
140
|
+
# into the verified payload; from_identity's GlobalID lookup then returns nil
|
|
141
|
+
# and raises RecordNotFound — the endpoint's 404 equivalent. A genuinely NEW
|
|
142
|
+
# record (no id) is untouched.
|
|
143
|
+
def inject_destroyed_gid(payload, component, klass)
|
|
144
|
+
return payload unless payload.is_a?(::Hash) && !payload.key?("gid")
|
|
145
|
+
|
|
146
|
+
record_ivar = klass.respond_to?(:reactive_record_ivar) && klass.reactive_record_ivar
|
|
147
|
+
record = record_ivar && component.instance_variable_get(record_ivar)
|
|
148
|
+
return payload unless record.respond_to?(:destroyed?) && record.destroyed? && record.id
|
|
149
|
+
|
|
150
|
+
payload.merge("gid" => record.to_gid.to_s)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Coerce the caller's kwargs the way the client's params arrive — every value
|
|
154
|
+
# is a string on the wire, so a `:integer` param really is cast, not passed
|
|
155
|
+
# through as a live Integer. Keys are stringified (the schema keys on
|
|
156
|
+
# strings), values deep-stringified so nested/array params coerce too, but
|
|
157
|
+
# UploadedFiles (issue #34) and nil pass through untouched.
|
|
158
|
+
def stringify_params(params)
|
|
159
|
+
params.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify_param_value(v) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def stringify_param_value(value)
|
|
163
|
+
case value
|
|
164
|
+
when ::Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify_param_value(v) }
|
|
165
|
+
when ::Array then value.map { stringify_param_value(it) }
|
|
166
|
+
when nil then nil
|
|
167
|
+
when ::String, ::Numeric, true, false then value.to_s
|
|
168
|
+
else value # UploadedFile and any other rich object rides through as-is
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Run inside the endpoint's transaction wrapper. `coerced` already has
|
|
173
|
+
# symbol keys (ParamSchema#coerce symbolizes to splat as kwargs), so we
|
|
174
|
+
# splat it exactly as the endpoint does. Kept tiny and dependency tolerant:
|
|
175
|
+
# no ActiveRecord (a state-only component in a bare app) just yields.
|
|
176
|
+
def run_reactive_action(component, action_def, coerced)
|
|
177
|
+
transaction_wrapper do
|
|
178
|
+
if coerced.any?
|
|
179
|
+
component.public_send(action_def.name, **coerced)
|
|
180
|
+
else
|
|
181
|
+
component.public_send(action_def.name)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def transaction_wrapper(&)
|
|
187
|
+
if defined?(::ActiveRecord::Base)
|
|
188
|
+
::ActiveRecord::Base.transaction(&)
|
|
189
|
+
else
|
|
190
|
+
yield
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The action's outcome, in the same terms the endpoint reasons about.
|
|
195
|
+
# Wraps the action's RETURN VALUE:
|
|
196
|
+
# * a Phlex::Reactive::Response — honored explicitly (replace/remove/
|
|
197
|
+
# redirect derived from it, exposed via #response).
|
|
198
|
+
# * anything else — the legacy contract (return value ignored by the
|
|
199
|
+
# endpoint, which falls back to the single self-replace). So a legacy
|
|
200
|
+
# action reports replace? true and #response nil.
|
|
201
|
+
#
|
|
202
|
+
# #streams reproduces the endpoint's actor streams (issue #110) — including
|
|
203
|
+
# the token-refresh injection response_streams performs — so a matcher can
|
|
204
|
+
# assert on exactly what the client would receive.
|
|
205
|
+
class Result
|
|
206
|
+
# The Response the action returned, or nil when it returned a legacy
|
|
207
|
+
# (non-Response) value.
|
|
208
|
+
attr_reader :response
|
|
209
|
+
|
|
210
|
+
# The component the action actually ran against — the instance REBUILT
|
|
211
|
+
# from the round-tripped identity, NOT the one passed to run_reactive (the
|
|
212
|
+
# endpoint always acts on a rebuilt instance). Read its ivars to assert
|
|
213
|
+
# state-backed changes: `run_reactive(c, :set, count: "42").component`.
|
|
214
|
+
attr_reader :component
|
|
215
|
+
|
|
216
|
+
# A stream re-renders `component`'s root (so its fresh token counts as the
|
|
217
|
+
# refresh) iff its action is one of these — never append/prepend, which
|
|
218
|
+
# insert children carrying their OWN token (issue #44). Same set the
|
|
219
|
+
# endpoint's carries_token_for? uses.
|
|
220
|
+
SELF_RENDER_ACTIONS = %w[replace update reactive:token].freeze
|
|
221
|
+
|
|
222
|
+
def initialize(returned:, component:)
|
|
223
|
+
@returned = returned
|
|
224
|
+
@component = component
|
|
225
|
+
@response = returned if returned.is_a?(Phlex::Reactive::Response)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# A legacy return (no Response) is the implicit single self-replace. A
|
|
229
|
+
# Response replaces unless it removes or redirects (render_self? is false
|
|
230
|
+
# for those, and for a .streams/.with that opts out).
|
|
231
|
+
def replace?
|
|
232
|
+
return true if @response.nil?
|
|
233
|
+
|
|
234
|
+
@response.render_self?
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def remove?
|
|
238
|
+
return false if @response.nil?
|
|
239
|
+
|
|
240
|
+
# Check the OPENING <turbo-stream> tag, not the whole string: a rendered
|
|
241
|
+
# body that happens to contain the literal `action="remove"` (a quoted
|
|
242
|
+
# code sample, say) must not false-positive. Same parse the token check
|
|
243
|
+
# and the matchers use.
|
|
244
|
+
@response.streams.any? do
|
|
245
|
+
open_tag = it[/<turbo-stream\b[^>]*>/] || ""
|
|
246
|
+
open_tag.include?(%(action="remove"))
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def redirect?
|
|
251
|
+
!@response.nil? && @response.redirect?
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def redirect_url
|
|
255
|
+
@response&.redirect_url
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# The actor's turbo-stream strings, exactly as the endpoint would render
|
|
259
|
+
# them (response_streams) — a legacy return is the single self-replace; a
|
|
260
|
+
# Response is honored, with the same token-refresh streams appended.
|
|
261
|
+
def streams
|
|
262
|
+
@streams ||= build_streams
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
private
|
|
266
|
+
|
|
267
|
+
# Mirrors ActionsController#response_streams. Kept in lockstep with the
|
|
268
|
+
# endpoint so the driver's streams match the real reply (issue #110).
|
|
269
|
+
def build_streams
|
|
270
|
+
return [@component.to_stream_replace] unless @response.is_a?(Phlex::Reactive::Response)
|
|
271
|
+
return [redirect_stream(@response.redirect_url)] if @response.redirect?
|
|
272
|
+
|
|
273
|
+
streams = @response.streams
|
|
274
|
+
if @response.refresh_token? && !carries_token_for?(streams, @response.token_component)
|
|
275
|
+
return [*streams, @response.token_component.to_stream_token]
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
if @response.render_self? && streams.none? { it.include?("data-reactive-token-value") }
|
|
279
|
+
streams = [@component.to_stream_replace, *streams]
|
|
280
|
+
end
|
|
281
|
+
streams
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def redirect_stream(url)
|
|
285
|
+
%(<turbo-stream action="reactive:visit" data-url="#{ERB::Util.html_escape(url)}"></turbo-stream>)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# A stream already carries `component`'s fresh token iff it re-renders that
|
|
289
|
+
# component's root at its own id AND carries a token — same test the
|
|
290
|
+
# endpoint uses to avoid doubling the token (issue #44).
|
|
291
|
+
def carries_token_for?(streams, component)
|
|
292
|
+
target = %(target="#{ERB::Util.html_escape(component.id)}")
|
|
293
|
+
streams.any? do
|
|
294
|
+
next false unless it.include?("data-reactive-token-value") && it.include?(target)
|
|
295
|
+
|
|
296
|
+
open_tag = it[/<turbo-stream\b[^>]*>/]
|
|
297
|
+
open_tag&.include?(target) && SELF_RENDER_ACTIONS.include?(open_tag[/\baction="([^"]+)"/, 1])
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# The RSpec matchers (have_reactive_replace/remove/token_for) are optional: load
|
|
306
|
+
# them only when RSpec is present, so a Minitest app can mix in TestHelpers and
|
|
307
|
+
# assert on Result predicates directly with no RSpec dependency.
|
|
308
|
+
require "phlex/reactive/test_helpers/matchers" if defined?(RSpec::Matchers)
|