phlex-reactive 0.9.5 → 0.11.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.
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # The blessed CLIENT-ONLY include (issue #180). A view that only shows/hides
6
+ # (reactive_show), filters (reactive_filter), computes client-side
7
+ # (reactive_compute), or runs zero-round-trip ops (on_client/js) — with NO
8
+ # server actions and NO signed token — includes this instead of the full
9
+ # Phlex::Reactive::Component.
10
+ #
11
+ # Why it exists: including the full Component pulls in Streamable, whose
12
+ # class/instance methods (replace/update/append/prepend/remove,
13
+ # to_stream_*) collide by NAME with an app's own Turbo-stream concern — so
14
+ # adopters cherry-picked an internal module and hand-rolled a token-less root
15
+ # (and defined a dummy #id "never read at render" just to keep
16
+ # phlex_reactive:doctor quiet). ClientBindings is that missing seam, blessed:
17
+ #
18
+ # class ExerciseFilter < ApplicationComponent
19
+ # include Phlex::Reactive::ClientBindings
20
+ #
21
+ # reactive_scope :form
22
+ # def reactive_values = { role: @role }
23
+ #
24
+ # def view_template
25
+ # div(**reactive_root) do # token-less: no #id required
26
+ # select(name: "form[role]") { role_options }
27
+ # div(**reactive_show(if: { role: "individual" })) { demographics }
28
+ # end
29
+ # end
30
+ # end
31
+ #
32
+ # It includes the DECLARATION DSL and the view Helpers, but NOT Streamable
33
+ # (nothing to clobber) or Identity (no token). So a ClientBindings class never
34
+ # enters Streamable.registered_classes and is invisible to
35
+ # phlex_reactive:doctor's #id check — no dummy #id, no nag.
36
+ #
37
+ # The client-only DSL macros (reactive_scope, reactive_compute) work; the
38
+ # SERVER-ACTION macros (action, reactive_record, reactive_state) RAISE at
39
+ # class-definition time — without Identity they could only silently no-op
40
+ # (sign nothing, dispatch nowhere), so the guard fails loudly (the guard lives
41
+ # in Component::DSL#require_server_actions!, keyed on Identity presence).
42
+ #
43
+ # Helpers is token-tolerant (issue #180): reactive_attrs emits a token only
44
+ # when reactive_token is available, and reactive_root uses #id only when
45
+ # defined — so the client-only view helpers work with no Identity/Streamable.
46
+ #
47
+ # The full Component includes ClientBindings too (ONE implementation of the
48
+ # client-only surface), then layers Streamable + Identity on top — so a
49
+ # token-bearing reactive_root is a SUPERSET, not a fork.
50
+ module ClientBindings
51
+ extend ActiveSupport::Concern
52
+
53
+ include Component::DSL
54
+ include Component::Helpers
55
+ end
56
+ end
57
+ end
@@ -29,10 +29,31 @@ module Phlex
29
29
  MIRROR_ID_SELECTOR = /\A#[A-Za-z_][\w-]*\z/
30
30
 
31
31
  class_methods do
32
+ # Guard the three SERVER-ACTION macros (issue #180). ClientBindings
33
+ # includes this DSL for its client-only macros (reactive_scope,
34
+ # reactive_compute) but does NOT include Identity/Streamable — so a
35
+ # signed token is never minted and no action endpoint dispatches to
36
+ # this class. Declaring action/reactive_record/reactive_state there
37
+ # would silently no-op (an action that can never fire, a record that
38
+ # signs nothing). Fail LOUDLY at class-definition time instead — the
39
+ # default-deny posture. Keyed on Identity presence: a full
40
+ # Phlex::Reactive::Component includes it (passes); a ClientBindings-only
41
+ # class does not (raises).
42
+ def require_server_actions!(macro)
43
+ return if include?(Phlex::Reactive::Component::Identity)
44
+
45
+ raise ArgumentError,
46
+ "#{self}: `#{macro}` needs the full Phlex::Reactive::Component — it signs a token / " \
47
+ "dispatches a server action, which Phlex::Reactive::ClientBindings deliberately omits " \
48
+ "(client-only: no token, no endpoint). Include Phlex::Reactive::Component for actions, " \
49
+ "or drop `#{macro}` and use the client-only helpers (reactive_show/filter/compute, on_client)."
50
+ end
51
+
32
52
  # Declare the ActiveRecord (GlobalID-able) record this component is
33
53
  # rebuilt from. The signed token carries its GlobalID; the server
34
54
  # re-finds it on each action. State lives in the DB.
35
55
  def reactive_record(name)
56
+ require_server_actions!(:reactive_record)
36
57
  Registry.write_scalar(self, :record_key, name.to_sym)
37
58
  end
38
59
 
@@ -44,9 +65,67 @@ module Phlex
44
65
  Registry.resolve_scalar(self, :record_key, :reactive_record_key)
45
66
  end
46
67
 
68
+ # Declare (with a name) OR read (bare) a form-field NAMESPACE for this
69
+ # component's bindings (issue #180). With `reactive_scope :form`,
70
+ # reactive_show / reactive_values use bare symbols (`:director`) and the
71
+ # client resolves the owned field as `[name="form[director]"]`; the root
72
+ # emits `data-reactive-scope="form"`. Bare `reactive_scope` reads the
73
+ # nearest declared scope up the ancestry (nil when none) — the
74
+ # dual-purpose form matching reactive_compute's getter/setter. A
75
+ # per-binding raw string field name still passes through unscoped.
76
+ def reactive_scope(name = nil)
77
+ return Registry.resolve_scalar(self, :scope, :reactive_scope) if name.nil?
78
+
79
+ scope = name.to_sym
80
+ # Issue #184: catch an action already declared with a schema nested under
81
+ # THIS scope key (the action-first declaration order) — the endpoint
82
+ # unwraps ONE scope level, so a schema pre-nested under it would be dead.
83
+ reactive_actions.each_value { assert_no_scope_double_nesting!(scope, it.params, it.name) }
84
+ Registry.write_scalar(self, :scope, scope)
85
+ end
86
+
87
+ # Raise a guided ArgumentError when a param schema is ALREADY nested under
88
+ # the scope key (issue #184): the endpoint peels one scope level, so
89
+ # params: { <scope>: { … } } would double-unwrap to nothing. Shared by both
90
+ # macros so neither declaration order can dodge the guard. A nested key that
91
+ # is NOT the scope (a real nested_attributes param) is fine.
92
+ def assert_no_scope_double_nesting!(scope, params, action_name)
93
+ return unless params.is_a?(Hash) && params[scope].is_a?(Hash)
94
+
95
+ raise ArgumentError,
96
+ "#{self}: action #{action_name.inspect} nests its schema under the reactive_scope " \
97
+ "key #{scope.inspect} (params: { #{scope}: { … } }). The endpoint unwraps one scope " \
98
+ "level already — declare the schema FLAT: params: { #{params[scope].keys.first || :field}: … }."
99
+ end
100
+
101
+ # Issue #184: ONE dirty-tracking declaration, class-level. Replaces
102
+ # reactive_root(track_dirty:, warn_unsaved:) + reactive_field(dirty:).
103
+ #
104
+ # reactive_dirty # track every field via the root
105
+ # reactive_dirty warn_unsaved: true # + warn before navigating away
106
+ # reactive_dirty only: %i[title] # track only these fields (per-field)
107
+ #
108
+ # Stored as a frozen config the render helpers read; the emitted DOM is
109
+ # byte-identical to the old kwargs, so the client is unchanged. `only:`
110
+ # switches to per-field descriptors (no root-level input delegation);
111
+ # otherwise the root delegates for the whole subtree.
112
+ def reactive_dirty(warn_unsaved: false, only: nil)
113
+ Registry.write_scalar(self, :dirty, {
114
+ warn_unsaved: warn_unsaved ? true : false,
115
+ only: only && Array(only).map(&:to_sym).freeze
116
+ }.freeze)
117
+ end
118
+
119
+ # The resolved dirty config (or nil when reactive_dirty was never
120
+ # declared), inherited through the Registry like every other scalar.
121
+ def reactive_dirty_config
122
+ Registry.resolve_scalar(self, :dirty, :reactive_dirty_config)
123
+ end
124
+
47
125
  # Opt into signed STATE for record-less components only.
48
126
  # reactive_state :count, :open
49
127
  def reactive_state(*names)
128
+ require_server_actions!(:reactive_state)
50
129
  Registry.append(self, :state_keys, names.map(&:to_sym))
51
130
  end
52
131
 
@@ -112,6 +191,14 @@ module Phlex
112
191
  # Array params accept BOTH a JSON array and a Rails-style index hash
113
192
  # ({ "0" => ..., "1" => ... }), so a fields_for collection works either way.
114
193
  def action(name, params: {})
194
+ require_server_actions!(:action)
195
+ # Issue #184: params: :symbol resolves a registered named schema.
196
+ params = Phlex::Reactive.param_schema(params) if params.is_a?(Symbol)
197
+ # If a scope is already declared, reject a schema nested under the scope
198
+ # key here (the scope-first declaration order). The action-first order is
199
+ # caught in reactive_scope.
200
+ scope = reactive_scope
201
+ assert_no_scope_double_nesting!(scope, params, name.to_sym) if scope
115
202
  Registry.write_entry(
116
203
  self, :actions, name.to_sym,
117
204
  Action.new(name: name.to_sym, params: params, schema: Phlex::Reactive::ParamSchema.compile(params))
@@ -127,12 +214,17 @@ module Phlex
127
214
  Registry.resolve_hash(self, :actions, :reactive_actions)
128
215
  end
129
216
 
217
+ # Issue #186: the fetch-one readers are removed — the plural
218
+ # reactive_actions hash IS the fetch-one. Guided errors name the rewrite.
130
219
  def reactive_action(name)
131
- reactive_actions[name.to_sym]
220
+ raise NoMethodError,
221
+ "reactive_action(#{name.inspect}) was removed in issue #186 — use reactive_actions[#{name.inspect}]"
132
222
  end
133
223
 
134
224
  def reactive_action?(name)
135
- reactive_actions.key?(name.to_sym)
225
+ raise NoMethodError,
226
+ "reactive_action?(#{name.inspect}) was removed in issue #186 — " \
227
+ "use reactive_actions.key?(#{name.inspect})"
136
228
  end
137
229
 
138
230
  # Opt out of the default-ON verify_authorized guard (issue #168).
@@ -181,9 +273,10 @@ module Phlex
181
273
  # empty: ItemsEmptyComponent, # optional empty-state component
182
274
  # size: -> { @record.items.size } # resolves the live size
183
275
  #
184
- # An action then governs the reply with one call:
185
- # reply.append(:items, item) # row append + count + clear empty-state
186
- # reply.remove(:items, id) # row remove + count + restore empty-state
276
+ # An action then governs the reply with one call (the collection is
277
+ # named by the to:/from: keyword, issue #182):
278
+ # reply.append(item, to: :items) # row append + count + clear empty-state
279
+ # reply.remove(id, from: :items) # row remove + count + restore empty-state
187
280
  #
188
281
  # count/empty/size are optional: a list with just rows omits them and the
189
282
  # corresponding stream isn't emitted. See Phlex::Reactive::Reply and the
@@ -199,12 +292,17 @@ module Phlex
199
292
  Registry.resolve_hash(self, :collections, :reactive_collections)
200
293
  end
201
294
 
295
+ # Issue #186: use the plural reactive_collections hash directly.
202
296
  def reactive_collection_def(name)
203
- reactive_collections[name.to_sym]
297
+ raise NoMethodError,
298
+ "reactive_collection_def(#{name.inspect}) was removed in issue #186 — " \
299
+ "use reactive_collections[#{name.inspect}]"
204
300
  end
205
301
 
206
302
  def reactive_collection?(name)
207
- reactive_collections.key?(name.to_sym)
303
+ raise NoMethodError,
304
+ "reactive_collection?(#{name.inspect}) was removed in issue #186 — " \
305
+ "use reactive_collections.key?(#{name.inspect})"
208
306
  end
209
307
 
210
308
  # Declare a client-side computation, OR (called with just a name) read
@@ -262,17 +360,13 @@ module Phlex
262
360
  # the client interpreter re-checks the same shape).
263
361
  def reactive_compute(name, inputs: nil, outputs: nil, reducer: nil, mirror: nil)
264
362
  if inputs.nil? && outputs.nil?
265
- # The bare form is the GETTER a mirror: passed here would be
266
- # silently dropped, so refuse it LOUDLY (declare-time validation,
267
- # same posture as normalize_compute_mirror's selector check).
268
- unless mirror.nil?
269
- raise ArgumentError,
270
- "#{self}: reactive_compute(#{name.inspect}, mirror: ...) without inputs:/outputs: " \
271
- "is the getter form — the mirror would be silently dropped. Declare the mirror " \
272
- "alongside inputs:/outputs:."
273
- end
274
-
275
- return reactive_compute_def(name)
363
+ # Issue #186: the bare getter form is removedreactive_compute is
364
+ # the SETTER now; read one compute via the plural hash. (Raising here
365
+ # keeps the getter/setter overload from silently misfiring.)
366
+ raise ArgumentError,
367
+ "reactive_compute(#{name.inspect}) getter was removed in issue #186 — " \
368
+ "use reactive_computes[#{name.inspect}]. reactive_compute is the declarative " \
369
+ "SETTER now: reactive_compute(:name, inputs:, outputs:)."
276
370
  end
277
371
 
278
372
  input_names, input_types = normalize_compute_inputs(inputs)
@@ -294,28 +388,59 @@ module Phlex
294
388
  # reactive_collection_def (issue #115). The bare reactive_compute(name)
295
389
  # getter above is a PERMANENT documented alias (it shipped in the same
296
390
  # minor series, #73); both stay, no deprecation.
391
+ # Issue #186: use the plural reactive_computes hash directly.
297
392
  def reactive_compute_def(name)
298
- reactive_computes[name.to_sym]
393
+ raise NoMethodError,
394
+ "reactive_compute_def(#{name.inspect}) was removed in issue #186 — " \
395
+ "use reactive_computes[#{name.inspect}]"
299
396
  end
300
397
 
301
398
  def reactive_compute?(name)
302
- reactive_computes.key?(name.to_sym)
399
+ raise NoMethodError,
400
+ "reactive_compute?(#{name.inspect}) was removed in issue #186 — " \
401
+ "use reactive_computes.key?(#{name.inspect})"
303
402
  end
304
403
 
305
- # Split the `inputs:` argument into [ordered names, types-or-nil] (issue
306
- # #104). A HASH ({ title: :string, qty: :number }) yields typed inputs —
307
- # the ordered keys plus a symbolized name→type map. Anything else (an
308
- # array, a bare symbol) is the UNTYPED form — ordered names and nil types,
309
- # so the array-form wire stays byte-identical and the client keeps its
310
- # numeric coercion. Named after the shape it normalizes.
404
+ # Split the `inputs:` argument into [ordered names, types-or-nil].
405
+ # THREE shapes, all degenerate cases of the issue-#183 PERMIT form
406
+ # (bare symbols default to :number; a trailing Hash types the exceptions):
407
+ #
408
+ # * a pure Hash ({ title: :string, qty: :number }) — the issue-#104 typed
409
+ # form. Ordered keys + a name→type map.
410
+ # * a permit Array with a trailing Hash ([:qty, { title: :string }]) —
411
+ # bare symbols typed :number, the Hash keys typed as declared. This is
412
+ # the one form the issue-#183 docs show.
413
+ # * a bare Array/symbol ([:price, :qty]) — the issue-#73 UNTYPED form.
414
+ # nil types, so the array-form wire stays byte-identical and the client
415
+ # keeps its numeric coercion.
416
+ #
417
+ # Named after the shape it normalizes.
311
418
  def normalize_compute_inputs(inputs)
312
- if inputs.is_a?(Hash)
313
- names = inputs.keys.map(&:to_sym)
314
- types = inputs.transform_keys(&:to_sym).transform_values(&:to_sym)
315
- [names, types]
316
- else
317
- [Array(inputs).map(&:to_sym), nil]
318
- end
419
+ return normalize_typed_compute_inputs(inputs) if inputs.is_a?(Hash)
420
+
421
+ # dup before popping: Array(inputs) returns the SAME object for an array
422
+ # arg, so an unguarded pop would mutate (or FrozenError on) a shared or
423
+ # frozen inputs array the caller still holds.
424
+ list = Array(inputs).dup
425
+ trailing = list.last.is_a?(Hash) ? list.pop : nil
426
+ names = list.map(&:to_sym)
427
+
428
+ # No trailing type Hash → the untyped array form (nil types).
429
+ return [names, nil] if trailing.nil?
430
+
431
+ # Permit form: bare names default to :number; the trailing Hash types
432
+ # the exceptions. Declaration order is bare names, then the Hash keys.
433
+ typed = trailing.transform_keys(&:to_sym).transform_values(&:to_sym)
434
+ ordered = names + typed.keys
435
+ types = names.to_h { [it, :number] }.merge(typed)
436
+ [ordered, types]
437
+ end
438
+
439
+ # A pure Hash of name => type (issue #104): ordered keys + the type map.
440
+ def normalize_typed_compute_inputs(inputs)
441
+ names = inputs.keys.map(&:to_sym)
442
+ types = inputs.transform_keys(&:to_sym).transform_values(&:to_sym)
443
+ [names, types]
319
444
  end
320
445
 
321
446
  # Normalize `mirror:` to { name => [id selectors] } (nil passes