phlex-reactive 0.4.8 → 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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +868 -3
  3. data/README.md +986 -32
  4. data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
  5. data/app/javascript/phlex/reactive/compute.js +52 -7
  6. data/app/javascript/phlex/reactive/compute.min.js +4 -0
  7. data/app/javascript/phlex/reactive/compute.min.js.map +10 -0
  8. data/app/javascript/phlex/reactive/confirm.min.js +4 -0
  9. data/app/javascript/phlex/reactive/confirm.min.js.map +10 -0
  10. data/app/javascript/phlex/reactive/reactive_controller.js +1487 -80
  11. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -0
  12. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -0
  13. data/lib/generators/phlex/reactive/component/USAGE +2 -1
  14. data/lib/generators/phlex/reactive/component/templates/component.rb.erb +1 -2
  15. data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +33 -2
  16. data/lib/generators/phlex/reactive/install/install_generator.rb +9 -3
  17. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +37 -0
  18. data/lib/phlex/reactive/component/dsl.rb +249 -0
  19. data/lib/phlex/reactive/component/helpers.rb +595 -0
  20. data/lib/phlex/reactive/component/identity.rb +92 -0
  21. data/lib/phlex/reactive/component/registry.rb +200 -0
  22. data/lib/phlex/reactive/component.rb +30 -442
  23. data/lib/phlex/reactive/doctor.rb +333 -0
  24. data/lib/phlex/reactive/engine.rb +27 -9
  25. data/lib/phlex/reactive/js.rb +222 -0
  26. data/lib/phlex/reactive/log_subscriber.rb +64 -0
  27. data/lib/phlex/reactive/param_schema.rb +390 -0
  28. data/lib/phlex/reactive/reply.rb +5 -3
  29. data/lib/phlex/reactive/response.rb +156 -16
  30. data/lib/phlex/reactive/stream.rb +98 -0
  31. data/lib/phlex/reactive/streamable.rb +307 -43
  32. data/lib/phlex/reactive/test_helpers/matchers.rb +112 -0
  33. data/lib/phlex/reactive/test_helpers.rb +308 -0
  34. data/lib/phlex/reactive/version.rb +1 -1
  35. data/lib/phlex/reactive.rb +329 -14
  36. data/lib/tasks/phlex_reactive.rake +14 -0
  37. metadata +19 -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)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.4.8"
5
+ VERSION = "0.9.0"
6
6
  end
7
7
  end