phlex-reactive 0.10.0 → 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.
@@ -76,7 +76,50 @@ module Phlex
76
76
  def reactive_scope(name = nil)
77
77
  return Registry.resolve_scalar(self, :scope, :reactive_scope) if name.nil?
78
78
 
79
- Registry.write_scalar(self, :scope, name.to_sym)
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)
80
123
  end
81
124
 
82
125
  # Opt into signed STATE for record-less components only.
@@ -149,6 +192,13 @@ module Phlex
149
192
  # ({ "0" => ..., "1" => ... }), so a fields_for collection works either way.
150
193
  def action(name, params: {})
151
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
152
202
  Registry.write_entry(
153
203
  self, :actions, name.to_sym,
154
204
  Action.new(name: name.to_sym, params: params, schema: Phlex::Reactive::ParamSchema.compile(params))
@@ -164,12 +214,17 @@ module Phlex
164
214
  Registry.resolve_hash(self, :actions, :reactive_actions)
165
215
  end
166
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.
167
219
  def reactive_action(name)
168
- reactive_actions[name.to_sym]
220
+ raise NoMethodError,
221
+ "reactive_action(#{name.inspect}) was removed in issue #186 — use reactive_actions[#{name.inspect}]"
169
222
  end
170
223
 
171
224
  def reactive_action?(name)
172
- 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})"
173
228
  end
174
229
 
175
230
  # Opt out of the default-ON verify_authorized guard (issue #168).
@@ -218,9 +273,10 @@ module Phlex
218
273
  # empty: ItemsEmptyComponent, # optional empty-state component
219
274
  # size: -> { @record.items.size } # resolves the live size
220
275
  #
221
- # An action then governs the reply with one call:
222
- # reply.append(:items, item) # row append + count + clear empty-state
223
- # 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
224
280
  #
225
281
  # count/empty/size are optional: a list with just rows omits them and the
226
282
  # corresponding stream isn't emitted. See Phlex::Reactive::Reply and the
@@ -236,12 +292,17 @@ module Phlex
236
292
  Registry.resolve_hash(self, :collections, :reactive_collections)
237
293
  end
238
294
 
295
+ # Issue #186: use the plural reactive_collections hash directly.
239
296
  def reactive_collection_def(name)
240
- 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}]"
241
300
  end
242
301
 
243
302
  def reactive_collection?(name)
244
- 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})"
245
306
  end
246
307
 
247
308
  # Declare a client-side computation, OR (called with just a name) read
@@ -299,17 +360,13 @@ module Phlex
299
360
  # the client interpreter re-checks the same shape).
300
361
  def reactive_compute(name, inputs: nil, outputs: nil, reducer: nil, mirror: nil)
301
362
  if inputs.nil? && outputs.nil?
302
- # The bare form is the GETTER a mirror: passed here would be
303
- # silently dropped, so refuse it LOUDLY (declare-time validation,
304
- # same posture as normalize_compute_mirror's selector check).
305
- unless mirror.nil?
306
- raise ArgumentError,
307
- "#{self}: reactive_compute(#{name.inspect}, mirror: ...) without inputs:/outputs: " \
308
- "is the getter form — the mirror would be silently dropped. Declare the mirror " \
309
- "alongside inputs:/outputs:."
310
- end
311
-
312
- 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:)."
313
370
  end
314
371
 
315
372
  input_names, input_types = normalize_compute_inputs(inputs)
@@ -331,28 +388,59 @@ module Phlex
331
388
  # reactive_collection_def (issue #115). The bare reactive_compute(name)
332
389
  # getter above is a PERMANENT documented alias (it shipped in the same
333
390
  # minor series, #73); both stay, no deprecation.
391
+ # Issue #186: use the plural reactive_computes hash directly.
334
392
  def reactive_compute_def(name)
335
- 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}]"
336
396
  end
337
397
 
338
398
  def reactive_compute?(name)
339
- 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})"
340
402
  end
341
403
 
342
- # Split the `inputs:` argument into [ordered names, types-or-nil] (issue
343
- # #104). A HASH ({ title: :string, qty: :number }) yields typed inputs —
344
- # the ordered keys plus a symbolized name→type map. Anything else (an
345
- # array, a bare symbol) is the UNTYPED form — ordered names and nil types,
346
- # so the array-form wire stays byte-identical and the client keeps its
347
- # 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.
348
418
  def normalize_compute_inputs(inputs)
349
- if inputs.is_a?(Hash)
350
- names = inputs.keys.map(&:to_sym)
351
- types = inputs.transform_keys(&:to_sym).transform_values(&:to_sym)
352
- [names, types]
353
- else
354
- [Array(inputs).map(&:to_sym), nil]
355
- 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]
356
444
  end
357
445
 
358
446
  # Normalize `mirror:` to { name => [id selectors] } (nil passes