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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +970 -0
  3. data/README.md +1134 -49
  4. data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
  5. data/app/javascript/phlex/reactive/compute.js +93 -0
  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 +1591 -56
  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 +37 -348
  23. data/lib/phlex/reactive/doctor.rb +333 -0
  24. data/lib/phlex/reactive/engine.rb +36 -7
  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 +20 -1
@@ -10,8 +10,10 @@ module Phlex
10
10
  # focused input — issue #28). No per-feature Stimulus controllers, no
11
11
  # hand-picked Turbo targets.
12
12
  #
13
- # Include alongside Phlex::Reactive::Streamable (which provides #id and the
14
- # re-render machinery).
13
+ # Including Component pulls in Phlex::Reactive::Streamable automatically
14
+ # (Concern dependency — Streamable lands on the base first, exactly the old
15
+ # manual order), so ONE include is enough. The legacy explicit double
16
+ # include remains a harmless no-op.
15
17
  #
16
18
  # === Security model (the decisive design choice) ===
17
19
  # We do NOT ship component STATE to the browser (no snapshot). The DOM
@@ -34,9 +36,8 @@ module Phlex
34
36
  # act — your action must still authorize the record. Action params pass
35
37
  # through a declared schema; nothing else reaches the method.
36
38
  #
37
- # Usage (record-backed):
39
+ # Usage (record-backed — #id defaults to dom_id(@todo), issue #81):
38
40
  # class Todos::Item < ApplicationComponent
39
- # include Phlex::Reactive::Streamable
40
41
  # include Phlex::Reactive::Component
41
42
  #
42
43
  # reactive_record :todo
@@ -44,7 +45,6 @@ module Phlex
44
45
  # action :rename, params: { title: :string }
45
46
  #
46
47
  # def initialize(todo:) = @todo = todo
47
- # def id = dom_id(@todo)
48
48
  #
49
49
  # def toggle = (authorize!(@todo, :update?); @todo.toggle!(:done))
50
50
  # def rename(title:) = (authorize!(@todo, :update?); @todo.update!(title:))
@@ -56,11 +56,37 @@ module Phlex
56
56
  # end
57
57
  # end
58
58
  # end
59
+ #
60
+ # Assembled from three cohesive concerns (issue #115) — one include, zero
61
+ # public API change:
62
+ # * Component::DSL — the five declaration registries (via
63
+ # Component::Registry) + from_identity
64
+ # * Component::Identity — reactive_token + the hot-path ivar precomputation
65
+ # * Component::Helpers — reply/js, reactive_attrs/root, on/on_client,
66
+ # the field/select/text bindings, and the nested-attributes helpers
59
67
  module Component
60
68
  extend ActiveSupport::Concern
61
-
62
- # A declared, client-invokable action and its param schema.
63
- Action = Data.define(:name, :params)
69
+ include Phlex::Reactive::Streamable
70
+
71
+ # A declared, client-invokable action and its param schema. `params` keeps
72
+ # the RAW declared hash (the readable form callers/specs inspect); `schema`
73
+ # is the compiled Phlex::Reactive::ParamSchema (issue #109) the endpoint
74
+ # coerces through — built ONCE at declaration so a typo'd type symbol
75
+ # raises Phlex::Reactive::UnknownParamType at class load, not at click time.
76
+ Action = Data.define(:name, :params, :schema)
77
+
78
+ # A declared client-side computation (data binding). `inputs`/`outputs` are
79
+ # the action-param names of the fields the reducer reads/writes; `reducer`
80
+ # is the key a JS function is registered under (Reactive.compute(key, fn)).
81
+ # The generic controller runs the reducer on `input` — writing outputs with
82
+ # NO round trip — then the debounced POST reconciles from the server reply.
83
+ #
84
+ # `input_types` (issue #104) is nil for the ARRAY input form (untyped ⇒ the
85
+ # client coerces every input through Number, the shipped behavior) and a
86
+ # { name => type } hash for the typed HASH form (:string reads the field
87
+ # value raw, :number coerces). `inputs` stays the ordered name list either
88
+ # way, so iteration order is preserved and the array-form wire is unchanged.
89
+ ComputeDefinition = Data.define(:name, :inputs, :outputs, :reducer, :input_types)
64
90
 
65
91
  # A declared add/remove-row collection (issue #35): the list contract tied
66
92
  # into one unit — the per-row item component, the container DOM id rows
@@ -80,346 +106,9 @@ module Phlex
80
106
  end
81
107
  end
82
108
 
83
- class_methods do
84
- # Declare the ActiveRecord (GlobalID-able) record this component is
85
- # rebuilt from. The signed token carries its GlobalID; the server
86
- # re-finds it on each action. State lives in the DB.
87
- def reactive_record(name)
88
- @reactive_record_key = name.to_sym
89
- remove_instance_variable(:@reactive_record_ivar) if defined?(@reactive_record_ivar)
90
- end
91
-
92
- def reactive_record_key
93
- return @reactive_record_key if defined?(@reactive_record_key)
94
-
95
- superclass.respond_to?(:reactive_record_key) ? superclass.reactive_record_key : nil
96
- end
97
-
98
- # Opt into signed STATE for record-less components only.
99
- # reactive_state :count, :open
100
- def reactive_state(*names)
101
- reactive_state_keys.concat(names.map(&:to_sym))
102
- @reactive_state_ivars = nil # rebuild the cached [key, ivar] pairs
103
- end
104
-
105
- def reactive_state_keys
106
- @reactive_state_keys ||= (superclass.respond_to?(:reactive_state_keys) ? superclass.reactive_state_keys.dup : [])
107
- end
108
-
109
- # Declare a client-invokable action with an optional param schema.
110
- # action :increment
111
- # action :rename, params: { title: :string }
112
- #
113
- # Param types are coerced server-side; anything not in the schema is
114
- # dropped before reaching your method (no mass assignment):
115
- # * Scalars — :string (default), :integer, :float, :boolean
116
- # * Array of scalar — wrap the type in an array: [:integer]
117
- # * Array of hash (Rails nested attributes) — wrap a hash schema:
118
- # action :save, params: {
119
- # date: :string,
120
- # bank_account_ids: [:integer],
121
- # invoice_items_attributes: [
122
- # { id: :integer, quantity: :float, price: :float, _destroy: :boolean }
123
- # ]
124
- # }
125
- # Array params accept BOTH a JSON array and a Rails-style index hash
126
- # ({ "0" => ..., "1" => ... }), so a fields_for collection works either way.
127
- def action(name, params: {})
128
- reactive_actions[name.to_sym] = Action.new(name: name.to_sym, params: params)
129
- end
130
-
131
- def reactive_actions
132
- @reactive_actions ||= (superclass.respond_to?(:reactive_actions) ? superclass.reactive_actions.dup : {})
133
- end
134
-
135
- def reactive_action(name)
136
- reactive_actions[name.to_sym]
137
- end
138
-
139
- def reactive_action?(name)
140
- reactive_actions.key?(name.to_sym)
141
- end
142
-
143
- # Declare an add/remove-row collection (issue #35) — the list contract
144
- # in one place, so actions append/prepend/remove a row WITHOUT
145
- # re-deriving the container id, count, and empty-state in every action:
146
- #
147
- # reactive_collection :items,
148
- # item: ItemRowComponent, # the per-row Streamable component
149
- # container: "items-list", # the DOM id rows live in
150
- # count: "items-count", # optional companion id (the size badge)
151
- # empty: ItemsEmptyComponent, # optional empty-state component
152
- # size: -> { @record.items.size } # resolves the live size
153
- #
154
- # An action then governs the reply with one call:
155
- # reply.append(:items, item) # row append + count + clear empty-state
156
- # reply.remove(:items, id) # row remove + count + restore empty-state
157
- #
158
- # count/empty/size are optional: a list with just rows omits them and the
159
- # corresponding stream isn't emitted. See Phlex::Reactive::Reply and the
160
- # README "Reactive collections" section.
161
- def reactive_collection(name, item:, container:, count: nil, empty: nil, size: nil)
162
- reactive_collections[name.to_sym] =
163
- CollectionDefinition.new(name: name.to_sym, item:, container:, count:, empty:, size:)
164
- end
165
-
166
- def reactive_collections
167
- @reactive_collections ||= superclass.respond_to?(:reactive_collections) ? superclass.reactive_collections.dup : {}
168
- end
169
-
170
- def reactive_collection_def(name)
171
- reactive_collections[name.to_sym]
172
- end
173
-
174
- def reactive_collection?(name)
175
- reactive_collections.key?(name.to_sym)
176
- end
177
-
178
- # The record's instance-variable symbol (e.g. :@todo), computed once.
179
- # reactive_token reads it on every render; interpolating :"@#{key}" each
180
- # time would allocate a symbol per render. Nil when record-less. Memoized
181
- # per class; reset alongside reactive_record so it can't go stale.
182
- def reactive_record_ivar
183
- return @reactive_record_ivar if defined?(@reactive_record_ivar)
184
-
185
- @reactive_record_ivar = reactive_record_key ? :"@#{reactive_record_key}" : nil
186
- end
187
-
188
- # [string_key, ivar_symbol] pairs for the signed state, computed once.
189
- # reactive_token walks these every render; precomputing the "count"/:@count
190
- # forms avoids a String + Symbol allocation per key per render. Memoized
191
- # per class; reset when reactive_state adds a key.
192
- def reactive_state_ivars
193
- @reactive_state_ivars ||= reactive_state_keys.map { [it.to_s, :"@#{it}"] }
194
- end
195
-
196
- # Rebuild a component instance from a verified identity payload. Called
197
- # by the action endpoint after the token signature is verified.
198
- #
199
- # A component may carry a record (re-found via GlobalID), signed state
200
- # (instance vars listed in reactive_state), or BOTH (the inline_edit
201
- # pattern: a record plus "which field / what mode"). We assemble the
202
- # init kwargs from whichever identity pieces are declared.
203
- def from_identity(payload)
204
- kwargs = {}
205
-
206
- if reactive_record_key
207
- record = GlobalID::Locator.locate(payload.fetch("gid"))
208
- raise(ActiveRecord::RecordNotFound, "reactive record missing") unless record
209
-
210
- kwargs[reactive_record_key] = record
211
- end
212
-
213
- if reactive_state_keys.any?
214
- state = payload.fetch("s", {})
215
- reactive_state_keys.each do
216
- # Use key presence, not the value: a signed `nil` (nullable state)
217
- # must round-trip distinctly. Only a genuinely absent key falls
218
- # back to the component's initialize default; `false` and `nil`
219
- # both survive.
220
- next unless state.key?(it.to_s)
221
-
222
- kwargs[it] = state[it.to_s]
223
- end
224
- end
225
-
226
- new(**kwargs)
227
- end
228
- end
229
-
230
- # The acting client's SSE connection id during the current action (nil
231
- # outside an action, or when the client isn't subscribed to a stream).
232
- # Pass it as `exclude:` when broadcasting from an action so the actor
233
- # doesn't receive the echo of its own change — it already gets the
234
- # action's HTTP response:
235
- #
236
- # def send_message(body:)
237
- # msg = ChatMessage.create!(room: @room, body:)
238
- # ChatMessage::Item.broadcast_append_to("chat", @room,
239
- # target: "messages", model: msg, exclude: reactive_connection_id)
240
- # end
241
- def reactive_connection_id
242
- Phlex::Reactive.current_connection_id
243
- end
244
-
245
- # Subject-bound reply builder — the preferred way to control an action's
246
- # reply. `reply.replace.flash(:error, msg)` reads cleaner than
247
- # `Phlex::Reactive::Response.replace(self).flash(:error, msg)`: the
248
- # component is the implicit subject (no `self` to thread) and there's no
249
- # constant to qualify (reply is a method, so a namespaced component needs
250
- # no `Response = …` alias). It returns the same immutable Response the
251
- # endpoint reads, so chaining and the legacy return-value contract are
252
- # unchanged. See Phlex::Reactive::Reply.
253
- #
254
- # def archive = reply.remove
255
- # def go_home = reply.redirect("/todos")
256
- # def update(name:) = (@account.update!(name:); reply.morph)
257
- def reply
258
- Phlex::Reactive::Reply.new(self)
259
- end
260
-
261
- # Root-element attributes: marks the element reactive and carries the
262
- # signed identity token. Spread onto the root:
263
- # div(id:, **reactive_attrs) { ... }
264
- def reactive_attrs
265
- {
266
- data: {
267
- controller: "reactive",
268
- reactive_token_value: reactive_token
269
- }
270
- }
271
- end
272
-
273
- # The WHOLE reactive root in one spread (issue #48). reactive_attrs alone
274
- # doesn't emit `id:`, so `id:` and `data-controller="reactive"` can land on
275
- # DIFFERENT elements — putting `id:` on a child leaves the controller root's
276
- # `id` empty, which silently breaks token threading (the client self-matches
277
- # its next token by `this.element.id`) and 403s on the next action.
278
- #
279
- # reactive_root binds the id to the SAME element as reactive_attrs, so the
280
- # footgun is unbuildable:
281
- # div(**reactive_root) { ... } # id + controller + token
282
- # div(**reactive_root(class: "card")) { ... } # add your own attrs
283
- #
284
- # mix deep-merges, so overrides add `class:`/`data:` without clobbering the
285
- # controller/token data: (a bare data: would). The id is resolved separately
286
- # (an explicit override wins as a clean replace, not a `mix` string-concat —
287
- # mix would join two String ids into "default override").
288
- def reactive_root(**overrides)
289
- root_id = overrides.delete(:id) || id
290
- mix({ **reactive_attrs }, overrides, { id: root_id })
291
- end
292
-
293
- # Attributes for an element that triggers an action.
294
- # button(**on(:toggle)) { "○" }
295
- # form(**on(:save, event: "submit")) { ... }
296
- # input(**on(:update, event: "input", debounce: 300)) # live-as-you-type
297
- #
298
- # Extra keyword args become explicit params merged over collected form
299
- # fields. For click triggers we force type="button" so a bare button
300
- # inside a <form> can't submit it and cause a full-page navigation.
301
- #
302
- # `debounce:` (milliseconds) coalesces rapid events (e.g. keystrokes on an
303
- # "input" trigger) into ONE round trip fired after the quiet period — so
304
- # live-update-as-you-type doesn't POST per keystroke. A blur flushes a
305
- # pending dispatch so the last edit is never dropped. Omit it for the
306
- # immediate-dispatch default.
307
- #
308
- # `confirm:` (a message string) gates the action behind a confirmation
309
- # prompt (issue #52). Destructive reactive triggers can't use Hotwire's
310
- # `data-turbo-confirm` — the reactive controller calls preventDefault and
311
- # enqueues the POST itself, so Turbo's confirm handling never runs. The
312
- # client shows window.confirm(message) FIRST and bails before any
313
- # enqueue/debounce if the user declines (and prevents the native default so
314
- # a `submit` trigger can't navigate on cancel). Omit it for no prompt.
315
- # button(**on(:destroy, confirm: "Really delete this item?")) { "Delete" }
316
- # The verbatim JSON for an empty explicit-params payload. The common
317
- # trigger (on(:increment), no params) hits this on EVERY render — skipping
318
- # params.to_json (which re-serializes {} to the same "{}" each time) avoids
319
- # a per-render allocation while keeping the wire format byte-identical.
320
- EMPTY_PARAMS_JSON = "{}"
321
-
322
- def on(action_name, event: "click", debounce: nil, confirm: nil, **params)
323
- attrs = {
324
- data: {
325
- action: "#{event}->reactive#dispatch",
326
- reactive_action_param: action_name.to_s,
327
- reactive_params_param: params.empty? ? EMPTY_PARAMS_JSON : params.to_json
328
- }
329
- }
330
- attrs[:data][:reactive_debounce_param] = debounce if debounce
331
- attrs[:data][:reactive_confirm_param] = confirm if confirm
332
- attrs[:type] = "button" if event == "click"
333
- attrs
334
- end
335
-
336
- # Bind a form control's `name` to an action param so its value travels with
337
- # the action — instead of hand-writing the magic `name: "value"` on every
338
- # input and silently getting no params when you forget it (issue #23).
339
- # Returns a Phlex attributes hash to spread onto any control:
340
- # input(**reactive_field(:value, value: @record.name))
341
- # select(**reactive_field(:status)) { ... }
342
- # Extra attrs merge over the binding; an explicit name: still wins (escape
343
- # hatch). The trigger (on(:save)) stays on the button, not the field — so
344
- # focusing the input doesn't dispatch and collapse edit mode.
345
- def reactive_field(param, **attrs)
346
- { name: param.to_s, **attrs }
347
- end
348
-
349
- # Render an <input> already bound to an action param (issue #23). Sugar for
350
- # input(**reactive_field(param, **attrs)); the value/type/etc. pass through.
351
- # reactive_input(:value, value: @record.name, type: "text")
352
- def reactive_input(param, **attrs)
353
- input(**reactive_field(param, **attrs))
354
- end
355
-
356
- # Render a <select> bound to an action param (issue #23). The options block
357
- # is the element's content, so the awkward FormBuilder positional split
358
- # (where name: lands after the options/html-options args) goes away:
359
- # reactive_select(:status) { @statuses.each { |s| option(value: s, selected: s == @record.status) { s } } }
360
- def reactive_select(param, **attrs, &)
361
- select(**reactive_field(param, **attrs), &)
362
- end
363
-
364
- # Map a declared nested param onto Rails' <assoc>_attributes, carrying the
365
- # existing associated record's id so accepts_nested_attributes_for matches
366
- # it IN PLACE instead of building a second one (issue #24). Returns the
367
- # update hash; pass it to update!:
368
- # def save(address:) = nested_update!(:address, address)
369
- # The id is only added when the association already exists, so the first
370
- # save (no associated record yet) creates one cleanly. The given attrs are
371
- # not mutated.
372
- def nested_attributes(association, attrs)
373
- merged = attrs.dup
374
- existing = reactive_record_for_nested.public_send(association)
375
- merged[:id] = existing.id if existing
376
-
377
- { "#{association}_attributes": merged }
378
- end
379
-
380
- # Map a nested param onto <assoc>_attributes (with id preservation) AND
381
- # apply it to the component's record in one call (issue #24). Extra keyword
382
- # attributes update alongside the association.
383
- # def save(address:, name:) = nested_update!(:address, address, name:)
384
- def nested_update!(association, attrs, **extra)
385
- reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
386
- end
387
-
388
- private
389
-
390
- # The component's record, for the nested-attributes helpers. Requires a
391
- # declared reactive_record (the nested helper only makes sense for a
392
- # record-backed component).
393
- def reactive_record_for_nested
394
- key = self.class.reactive_record_key
395
- raise Error, "#{self.class} must declare `reactive_record` to use nested_update!/nested_attributes" unless key
396
-
397
- instance_variable_get(:"@#{key}")
398
- end
399
-
400
- # Signed identity payload: the class name plus whichever identity pieces
401
- # the component declares — a record GlobalID (`gid`), signed state (`s`),
402
- # or both. Keeping them in ONE MessageVerifier payload makes the state
403
- # (e.g. which column an inline_edit may write) tamper-proof alongside the
404
- # record. Record-only ({c, gid}) and state-only ({c, s}) shapes are
405
- # unchanged.
406
- def reactive_token
407
- klass = self.class
408
- payload = { "c" => klass.name }
409
-
410
- if (record_ivar = klass.reactive_record_ivar)
411
- payload["gid"] = instance_variable_get(record_ivar).to_gid.to_s
412
- end
413
-
414
- state_ivars = klass.reactive_state_ivars
415
- unless state_ivars.empty?
416
- state = {}
417
- state_ivars.each { |key, ivar| state[key] = instance_variable_get(ivar).as_json }
418
- payload["s"] = state
419
- end
420
-
421
- Phlex::Reactive.sign(payload)
422
- end
109
+ include DSL
110
+ include Identity
111
+ include Helpers
423
112
  end
424
113
  end
425
114
  end