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
@@ -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,18 +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
69
+ include Phlex::Reactive::Streamable
61
70
 
62
- # A declared, client-invokable action and its param schema.
63
- Action = Data.define(:name, :params)
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)
64
77
 
65
78
  # A declared client-side computation (data binding). `inputs`/`outputs` are
66
79
  # the action-param names of the fields the reducer reads/writes; `reducer`
67
80
  # is the key a JS function is registered under (Reactive.compute(key, fn)).
68
81
  # The generic controller runs the reducer on `input` — writing outputs with
69
82
  # NO round trip — then the debounced POST reconciles from the server reply.
70
- ComputeDefinition = Data.define(:name, :inputs, :outputs, :reducer)
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)
71
90
 
72
91
  # A declared add/remove-row collection (issue #35): the list contract tied
73
92
  # into one unit — the per-row item component, the container DOM id rows
@@ -87,440 +106,9 @@ module Phlex
87
106
  end
88
107
  end
89
108
 
90
- class_methods do
91
- # Declare the ActiveRecord (GlobalID-able) record this component is
92
- # rebuilt from. The signed token carries its GlobalID; the server
93
- # re-finds it on each action. State lives in the DB.
94
- def reactive_record(name)
95
- @reactive_record_key = name.to_sym
96
- remove_instance_variable(:@reactive_record_ivar) if defined?(@reactive_record_ivar)
97
- end
98
-
99
- def reactive_record_key
100
- return @reactive_record_key if defined?(@reactive_record_key)
101
-
102
- superclass.respond_to?(:reactive_record_key) ? superclass.reactive_record_key : nil
103
- end
104
-
105
- # Opt into signed STATE for record-less components only.
106
- # reactive_state :count, :open
107
- def reactive_state(*names)
108
- reactive_state_keys.concat(names.map(&:to_sym))
109
- @reactive_state_ivars = nil # rebuild the cached [key, ivar] pairs
110
- end
111
-
112
- def reactive_state_keys
113
- @reactive_state_keys ||= (superclass.respond_to?(:reactive_state_keys) ? superclass.reactive_state_keys.dup : [])
114
- end
115
-
116
- # Declare a client-invokable action with an optional param schema.
117
- # action :increment
118
- # action :rename, params: { title: :string }
119
- #
120
- # Param types are coerced server-side; anything not in the schema is
121
- # dropped before reaching your method (no mass assignment):
122
- # * Scalars — :string (default), :integer, :float, :boolean
123
- # * Array of scalar — wrap the type in an array: [:integer]
124
- # * Array of hash (Rails nested attributes) — wrap a hash schema:
125
- # action :save, params: {
126
- # date: :string,
127
- # bank_account_ids: [:integer],
128
- # invoice_items_attributes: [
129
- # { id: :integer, quantity: :float, price: :float, _destroy: :boolean }
130
- # ]
131
- # }
132
- # Array params accept BOTH a JSON array and a Rails-style index hash
133
- # ({ "0" => ..., "1" => ... }), so a fields_for collection works either way.
134
- def action(name, params: {})
135
- reactive_actions[name.to_sym] = Action.new(name: name.to_sym, params: params)
136
- end
137
-
138
- def reactive_actions
139
- @reactive_actions ||= (superclass.respond_to?(:reactive_actions) ? superclass.reactive_actions.dup : {})
140
- end
141
-
142
- def reactive_action(name)
143
- reactive_actions[name.to_sym]
144
- end
145
-
146
- def reactive_action?(name)
147
- reactive_actions.key?(name.to_sym)
148
- end
149
-
150
- # Declare an add/remove-row collection (issue #35) — the list contract
151
- # in one place, so actions append/prepend/remove a row WITHOUT
152
- # re-deriving the container id, count, and empty-state in every action:
153
- #
154
- # reactive_collection :items,
155
- # item: ItemRowComponent, # the per-row Streamable component
156
- # container: "items-list", # the DOM id rows live in
157
- # count: "items-count", # optional companion id (the size badge)
158
- # empty: ItemsEmptyComponent, # optional empty-state component
159
- # size: -> { @record.items.size } # resolves the live size
160
- #
161
- # An action then governs the reply with one call:
162
- # reply.append(:items, item) # row append + count + clear empty-state
163
- # reply.remove(:items, id) # row remove + count + restore empty-state
164
- #
165
- # count/empty/size are optional: a list with just rows omits them and the
166
- # corresponding stream isn't emitted. See Phlex::Reactive::Reply and the
167
- # README "Reactive collections" section.
168
- def reactive_collection(name, item:, container:, count: nil, empty: nil, size: nil)
169
- reactive_collections[name.to_sym] =
170
- CollectionDefinition.new(name: name.to_sym, item:, container:, count:, empty:, size:)
171
- end
172
-
173
- def reactive_collections
174
- @reactive_collections ||= superclass.respond_to?(:reactive_collections) ? superclass.reactive_collections.dup : {}
175
- end
176
-
177
- def reactive_collection_def(name)
178
- reactive_collections[name.to_sym]
179
- end
180
-
181
- def reactive_collection?(name)
182
- reactive_collections.key?(name.to_sym)
183
- end
184
-
185
- # Declare a client-side computation, OR (called with just a name) read
186
- # one back. Dual-purpose so a component reads `reactive_compute :split,
187
- # inputs: …, outputs: …` and the endpoint/helpers read
188
- # `reactive_compute(:split)` — mirroring how `on`/`reactive_field` keep a
189
- # tight surface. `reducer:` defaults to the compute name.
190
- #
191
- # reactive_compute :payment_split,
192
- # inputs: %i[allowance cash leasing total], # fields the JS reducer reads
193
- # outputs: %i[allowance cash leasing] # fields it writes (no round trip)
194
- #
195
- # Register the matching JS once at boot:
196
- # import { setComputeReducer } from "phlex/reactive/compute"
197
- # setComputeReducer("payment_split", ({ allowance, cash, leasing, total }) => ({ … }))
198
- def reactive_compute(name, inputs: nil, outputs: nil, reducer: nil)
199
- return reactive_computes[name.to_sym] if inputs.nil? && outputs.nil?
200
-
201
- reactive_computes[name.to_sym] = ComputeDefinition.new(
202
- name: name.to_sym, inputs: Array(inputs).map(&:to_sym),
203
- outputs: Array(outputs).map(&:to_sym), reducer: (reducer || name).to_s
204
- )
205
- end
206
-
207
- def reactive_computes
208
- @reactive_computes ||= superclass.respond_to?(:reactive_computes) ? superclass.reactive_computes.dup : {}
209
- end
210
-
211
- def reactive_compute?(name)
212
- reactive_computes.key?(name.to_sym)
213
- end
214
-
215
- # The record's instance-variable symbol (e.g. :@todo), computed once.
216
- # reactive_token reads it on every render; interpolating :"@#{key}" each
217
- # time would allocate a symbol per render. Nil when record-less. Memoized
218
- # per class; reset alongside reactive_record so it can't go stale.
219
- def reactive_record_ivar
220
- return @reactive_record_ivar if defined?(@reactive_record_ivar)
221
-
222
- @reactive_record_ivar = reactive_record_key ? :"@#{reactive_record_key}" : nil
223
- end
224
-
225
- # [string_key, ivar_symbol] pairs for the signed state, computed once.
226
- # reactive_token walks these every render; precomputing the "count"/:@count
227
- # forms avoids a String + Symbol allocation per key per render. Memoized
228
- # per class; reset when reactive_state adds a key.
229
- def reactive_state_ivars
230
- @reactive_state_ivars ||= reactive_state_keys.map { [it.to_s, :"@#{it}"] }
231
- end
232
-
233
- # Rebuild a component instance from a verified identity payload. Called
234
- # by the action endpoint after the token signature is verified.
235
- #
236
- # A component may carry a record (re-found via GlobalID), signed state
237
- # (instance vars listed in reactive_state), or BOTH (the inline_edit
238
- # pattern: a record plus "which field / what mode"). We assemble the
239
- # init kwargs from whichever identity pieces are declared.
240
- def from_identity(payload)
241
- kwargs = {}
242
-
243
- if reactive_record_key
244
- record = GlobalID::Locator.locate(payload.fetch("gid"))
245
- raise(ActiveRecord::RecordNotFound, "reactive record missing") unless record
246
-
247
- kwargs[reactive_record_key] = record
248
- end
249
-
250
- if reactive_state_keys.any?
251
- state = payload.fetch("s", {})
252
- reactive_state_keys.each do
253
- # Use key presence, not the value: a signed `nil` (nullable state)
254
- # must round-trip distinctly. Only a genuinely absent key falls
255
- # back to the component's initialize default; `false` and `nil`
256
- # both survive.
257
- next unless state.key?(it.to_s)
258
-
259
- kwargs[it] = state[it.to_s]
260
- end
261
- end
262
-
263
- new(**kwargs)
264
- end
265
- end
266
-
267
- # The acting client's SSE connection id during the current action (nil
268
- # outside an action, or when the client isn't subscribed to a stream).
269
- # Pass it as `exclude:` when broadcasting from an action so the actor
270
- # doesn't receive the echo of its own change — it already gets the
271
- # action's HTTP response:
272
- #
273
- # def send_message(body:)
274
- # msg = ChatMessage.create!(room: @room, body:)
275
- # ChatMessage::Item.broadcast_append_to("chat", @room,
276
- # target: "messages", model: msg, exclude: reactive_connection_id)
277
- # end
278
- def reactive_connection_id
279
- Phlex::Reactive.current_connection_id
280
- end
281
-
282
- # Subject-bound reply builder — the preferred way to control an action's
283
- # reply. `reply.replace.flash(:error, msg)` reads cleaner than
284
- # `Phlex::Reactive::Response.replace(self).flash(:error, msg)`: the
285
- # component is the implicit subject (no `self` to thread) and there's no
286
- # constant to qualify (reply is a method, so a namespaced component needs
287
- # no `Response = …` alias). It returns the same immutable Response the
288
- # endpoint reads, so chaining and the legacy return-value contract are
289
- # unchanged. See Phlex::Reactive::Reply.
290
- #
291
- # def archive = reply.remove
292
- # def go_home = reply.redirect("/todos")
293
- # def update(name:) = (@account.update!(name:); reply.morph)
294
- def reply
295
- Phlex::Reactive::Reply.new(self)
296
- end
297
-
298
- # Root-element attributes: marks the element reactive and carries the
299
- # signed identity token. Spread onto the root:
300
- # div(id:, **reactive_attrs) { ... }
301
- def reactive_attrs
302
- {
303
- data: {
304
- controller: "reactive",
305
- reactive_token_value: reactive_token
306
- }
307
- }
308
- end
309
-
310
- # The WHOLE reactive root in one spread (issue #48). reactive_attrs alone
311
- # doesn't emit `id:`, so `id:` and `data-controller="reactive"` can land on
312
- # DIFFERENT elements — putting `id:` on a child leaves the controller root's
313
- # `id` empty, which silently breaks token threading (the client self-matches
314
- # its next token by `this.element.id`) and 403s on the next action.
315
- #
316
- # reactive_root binds the id to the SAME element as reactive_attrs, so the
317
- # footgun is unbuildable:
318
- # div(**reactive_root) { ... } # id + controller + token
319
- # div(**reactive_root(class: "card")) { ... } # add your own attrs
320
- #
321
- # mix deep-merges, so overrides add `class:`/`data:` without clobbering the
322
- # controller/token data: (a bare data: would). The id is resolved separately
323
- # (an explicit override wins as a clean replace, not a `mix` string-concat —
324
- # mix would join two String ids into "default override").
325
- def reactive_root(**overrides)
326
- root_id = overrides.delete(:id) || id
327
- mix({ **reactive_attrs }, overrides, { id: root_id })
328
- end
329
-
330
- # Attributes for an element that triggers an action.
331
- # button(**on(:toggle)) { "○" }
332
- # form(**on(:save, event: "submit")) { ... }
333
- # input(**on(:update, event: "input", debounce: 300)) # live-as-you-type
334
- #
335
- # Extra keyword args become explicit params merged over collected form
336
- # fields. For click triggers we force type="button" so a bare button
337
- # inside a <form> can't submit it and cause a full-page navigation.
338
- #
339
- # `debounce:` (milliseconds) coalesces rapid events (e.g. keystrokes on an
340
- # "input" trigger) into ONE round trip fired after the quiet period — so
341
- # live-update-as-you-type doesn't POST per keystroke. A blur flushes a
342
- # pending dispatch so the last edit is never dropped. Omit it for the
343
- # immediate-dispatch default.
344
- #
345
- # `confirm:` (a message string) gates the action behind a confirmation
346
- # prompt (issue #52). Destructive reactive triggers can't use Hotwire's
347
- # `data-turbo-confirm` — the reactive controller calls preventDefault and
348
- # enqueues the POST itself, so Turbo's confirm handling never runs. The
349
- # client shows window.confirm(message) FIRST and bails before any
350
- # enqueue/debounce if the user declines (and prevents the native default so
351
- # a `submit` trigger can't navigate on cancel). Omit it for no prompt.
352
- # button(**on(:destroy, confirm: "Really delete this item?")) { "Delete" }
353
- #
354
- # `event:` is interpolated verbatim into the Stimulus action descriptor
355
- # (`#{event}->reactive#dispatch`), so any Stimulus event string works —
356
- # including its native KEYBOARD FILTERS. Pass `event: "keydown.enter"` for
357
- # Enter-to-submit or `event: "keydown.esc"` for Escape-to-cancel, and the
358
- # action fires only on that key — no separate option, no client code, and
359
- # `key` stays free as an ordinary action-param name (on(:switch, key: …)):
360
- # input(**on(:add, event: "keydown.enter")) # Enter submits
361
- # button(**on(:cancel, event: "keydown.esc")) # Escape cancels
362
- #
363
- # `listnav:` (a CSS selector for the option elements) adds keyboard list
364
- # navigation to a search/combobox trigger (issue #72). It appends Stimulus
365
- # keyboard filters to the SAME element's data-action so Arrow Up/Down move a
366
- # client-side highlight among the options, Enter picks the highlighted one
367
- # (clicking its own reactive trigger — so selection stays a signed action),
368
- # and Escape clears — all with NO server round trip for the highlight (the
369
- # controller's listnav* handlers, like #recompute). Omit it for no nav.
370
- # input(**on(:search, event: "input", debounce: 300, listnav: "[role=option]"))
371
- # The verbatim JSON for an empty explicit-params payload. The common
372
- # trigger (on(:increment), no params) hits this on EVERY render — skipping
373
- # params.to_json (which re-serializes {} to the same "{}" each time) avoids
374
- # a per-render allocation while keeping the wire format byte-identical.
375
- EMPTY_PARAMS_JSON = "{}"
376
-
377
- # The keyboard filters appended to a listnav trigger's data-action. Each is
378
- # a client-only handler (no POST) except Enter, which clicks the highlighted
379
- # option's own reactive trigger. Stimulus binds these natively.
380
- LISTNAV_ACTIONS = [
381
- "keydown.down->reactive#listnavNext",
382
- "keydown.up->reactive#listnavPrev",
383
- "keydown.enter->reactive#listnavPick",
384
- "keydown.esc->reactive#listnavClose"
385
- ].freeze
386
-
387
- def on(action_name, event: "click", debounce: nil, confirm: nil, listnav: nil, **params)
388
- action = "#{event}->reactive#dispatch"
389
- action = "#{action} #{LISTNAV_ACTIONS.join(" ")}" if listnav
390
- attrs = {
391
- data: {
392
- action:,
393
- reactive_action_param: action_name.to_s,
394
- reactive_params_param: params.empty? ? EMPTY_PARAMS_JSON : params.to_json
395
- }
396
- }
397
- attrs[:data][:reactive_debounce_param] = debounce if debounce
398
- attrs[:data][:reactive_confirm_param] = confirm if confirm
399
- attrs[:data][:reactive_listnav_option_param] = listnav if listnav
400
- attrs[:type] = "button" if event == "click"
401
- attrs
402
- end
403
-
404
- # Bind a form control's `name` to an action param so its value travels with
405
- # the action — instead of hand-writing the magic `name: "value"` on every
406
- # input and silently getting no params when you forget it (issue #23).
407
- # Returns a Phlex attributes hash to spread onto any control:
408
- # input(**reactive_field(:value, value: @record.name))
409
- # select(**reactive_field(:status)) { ... }
410
- # Extra attrs merge over the binding; an explicit name: still wins (escape
411
- # hatch). The trigger (on(:save)) stays on the button, not the field — so
412
- # focusing the input doesn't dispatch and collapse edit mode.
413
- def reactive_field(param, **attrs)
414
- { name: param.to_s, **attrs }
415
- end
416
-
417
- # Render an <input> already bound to an action param (issue #23). Sugar for
418
- # input(**reactive_field(param, **attrs)); the value/type/etc. pass through.
419
- # reactive_input(:value, value: @record.name, type: "text")
420
- def reactive_input(param, **attrs)
421
- input(**reactive_field(param, **attrs))
422
- end
423
-
424
- # Data attributes declaring a client-side compute for the root element.
425
- # Spread ALONGSIDE reactive_root so the generic controller can find the
426
- # reducer and the named input/output fields inside this root:
427
- # div(**mix(reactive_root, reactive_compute_attrs(:payment_split))) { … }
428
- #
429
- # It emits the reducer key plus the input/output field names as JSON so the
430
- # client runs the reducer on `input`, writes the outputs with no round trip,
431
- # then the debounced POST reconciles from the server reply. Raises for an
432
- # undeclared compute — a silent no-op would leave the field wiring dead.
433
- def reactive_compute_attrs(name)
434
- definition = self.class.reactive_compute(name)
435
- raise Error, "#{self.class} has no reactive_compute #{name.inspect}" unless definition
436
-
437
- {
438
- data: {
439
- reactive_compute_reducer_param: definition.reducer,
440
- reactive_compute_inputs_param: definition.inputs.map(&:to_s).to_json,
441
- reactive_compute_outputs_param: definition.outputs.map(&:to_s).to_json
442
- }
443
- }
444
- end
445
-
446
- # Render a <select> bound to an action param (issue #23). The options block
447
- # is the element's content, so the awkward FormBuilder positional split
448
- # (where name: lands after the options/html-options args) goes away:
449
- # reactive_select(:status) { @statuses.each { |s| option(value: s, selected: s == @record.status) { s } } }
450
- def reactive_select(param, **attrs, &)
451
- select(**reactive_field(param, **attrs), &)
452
- end
453
-
454
- # Map a declared nested param onto Rails' <assoc>_attributes, carrying the
455
- # existing associated record's id so accepts_nested_attributes_for matches
456
- # it IN PLACE instead of building a second one (issue #24). Returns the
457
- # update hash; pass it to update!:
458
- # def save(address:) = nested_update!(:address, address)
459
- # The id is only added when the association already exists, so the first
460
- # save (no associated record yet) creates one cleanly. The given attrs are
461
- # not mutated.
462
- def nested_attributes(association, attrs)
463
- merged = attrs.dup
464
- existing = reactive_record_for_nested.public_send(association)
465
- merged[:id] = existing.id if existing
466
-
467
- { "#{association}_attributes": merged }
468
- end
469
-
470
- # Map a nested param onto <assoc>_attributes (with id preservation) AND
471
- # apply it to the component's record in one call (issue #24). Extra keyword
472
- # attributes update alongside the association.
473
- # def save(address:, name:) = nested_update!(:address, address, name:)
474
- def nested_update!(association, attrs, **extra)
475
- reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
476
- end
477
-
478
- private
479
-
480
- # The component's record, for the nested-attributes helpers. Requires a
481
- # declared reactive_record (the nested helper only makes sense for a
482
- # record-backed component).
483
- def reactive_record_for_nested
484
- key = self.class.reactive_record_key
485
- raise Error, "#{self.class} must declare `reactive_record` to use nested_update!/nested_attributes" unless key
486
-
487
- instance_variable_get(:"@#{key}")
488
- end
489
-
490
- # Signed identity payload: the class name plus whichever identity pieces
491
- # the component declares — a record GlobalID (`gid`), signed state (`s`),
492
- # or both. Keeping them in ONE MessageVerifier payload makes the state
493
- # (e.g. which column an inline_edit may write) tamper-proof alongside the
494
- # record. Record-only ({c, gid}) and state-only ({c, s}) shapes are
495
- # unchanged.
496
- #
497
- # A record that is NOT YET PERSISTED (new_record?) has no id → no GlobalID
498
- # (to_gid raises MissingModelIdError). A record-backed component may render
499
- # such a draft (an unsaved order the user is building): we OMIT gid and rely
500
- # on the declared state (reactive_state) as the draft seed, so the token
501
- # still signs cleanly and the client controller mounts. The draft is then
502
- # driven client-side (reactive_compute) until it's saved; once persisted, a
503
- # re-render signs the gid as usual. If the record is unsaved AND no state is
504
- # declared, the token carries just {c} — enough to mount, but with no
505
- # identity to round-trip, so declare reactive_state for a draft you sync.
506
- def reactive_token
507
- klass = self.class
508
- payload = { "c" => klass.name }
509
-
510
- if (record_ivar = klass.reactive_record_ivar)
511
- record = instance_variable_get(record_ivar)
512
- payload["gid"] = record.to_gid.to_s unless record.respond_to?(:persisted?) && !record.persisted?
513
- end
514
-
515
- state_ivars = klass.reactive_state_ivars
516
- unless state_ivars.empty?
517
- state = {}
518
- state_ivars.each { |key, ivar| state[key] = instance_variable_get(ivar).as_json }
519
- payload["s"] = state
520
- end
521
-
522
- Phlex::Reactive.sign(payload)
523
- end
109
+ include DSL
110
+ include Identity
111
+ include Helpers
524
112
  end
525
113
  end
526
114
  end