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.
@@ -85,7 +85,7 @@ module Phlex
85
85
  end
86
86
 
87
87
  event[:outcome] = :ok
88
- stream = payload["m"] == "morph" ? component.to_stream_morph : component.to_stream_replace
88
+ stream = component.to_stream_replace(morph: payload["m"] == "morph")
89
89
  render turbo_stream: stream
90
90
  rescue Phlex::Reactive::InvalidToken => e
91
91
  event[:outcome] = :invalid_token
@@ -119,7 +119,7 @@ module Phlex
119
119
  payload = verified_payload
120
120
  component_class = resolve_component(payload["c"])
121
121
  event[:component] = component_class.name
122
- action_def = component_class.reactive_action(reactive_action_name)
122
+ action_def = component_class.reactive_actions[reactive_action_name.to_sym]
123
123
 
124
124
  # default-deny
125
125
  unless action_def
@@ -189,7 +189,9 @@ module Phlex
189
189
  return unless callable
190
190
 
191
191
  message = callable.call(kind)
192
- Phlex::Reactive::Response.flash_stream(:error, message, target: Phlex::Reactive.flash_target)
192
+ # flash_stream is a private Response builder (issue #182) the endpoint's
193
+ # error path is an internal caller, so reach it via send.
194
+ Phlex::Reactive::Response.send(:flash_stream, :error, message, target: Phlex::Reactive.flash_target)
193
195
  rescue => e # rubocop:disable Style/RescueStandardError
194
196
  ::Rails.logger&.warn("[phlex-reactive] error_flash raised: #{e.message}") if defined?(::Rails) &&
195
197
  ::Rails.respond_to?(:logger)
@@ -298,7 +300,7 @@ module Phlex
298
300
 
299
301
  streams = result.streams
300
302
 
301
- # Partial update (Response.streams / reply.streams, issue #30): the action
303
+ # Partial update (reply.streams, issue #30): the action
302
304
  # re-rendered only PART of the component and opted out of the full-self
303
305
  # replace. Append a tiny token-only stream so the signed token still rolls
304
306
  # forward WITHOUT re-rendering (and clobbering) the live inputs. Skip it
@@ -320,7 +322,7 @@ module Phlex
320
322
  # for replace AND update of self (both re-render the root via
321
323
  # render_component, carrying the token), and still adds the fallback
322
324
  # replace when a hand-built `with(...)` stream omits it. Idempotent: a
323
- # Response.replace(self)/update(self) already carries the token, so we
325
+ # reply.replace/update already carries the token, so we
324
326
  # don't double the self-render.
325
327
  #
326
328
  # GUARD 2 (issue #114): GLOBAL, un-scoped — "does ANY stream carry a
@@ -328,8 +330,21 @@ module Phlex
328
330
  # ground-truth flag (rx_carries_token?), a raw string from a substring
329
331
  # scan. Deliberately NOT rx_refreshes_token_for? (target-scoped): scoping
330
332
  # this guard would regress update/morph of self on an aliased id.
333
+ #
334
+ # A self-render reply (reply.replace/update/morph — subject_component
335
+ # set) whose streams somehow carry no token gets the full self-replace
336
+ # (defensive, unchanged). A companion-only reply.with (NO subject, NO
337
+ # token_component) that doesn't re-render the root gets a token-ONLY
338
+ # refresh instead — issue #180 automatic token refresh: reply.with and
339
+ # reply.streams converge, the author never picks a verb to keep the token
340
+ # fresh, and a live input is never clobbered by a forced replace.
331
341
  elsif result.render_self? && streams.none? { stream_carries_token?(it) }
332
- streams = [component.to_stream_replace, *streams]
342
+ streams =
343
+ if result.subject_component
344
+ [component.to_stream_replace, *streams]
345
+ else
346
+ [*streams, component.to_stream_token]
347
+ end
333
348
  end
334
349
 
335
350
  append_deferred_streams(streams, result)
@@ -470,13 +485,31 @@ module Phlex
470
485
  # skipped — zero extra work on the production path.
471
486
  def coerce_params(action_def, component_class: nil, action_name: nil)
472
487
  dropped = Phlex::Reactive.verbose_errors ? [] : nil
473
- raw = params.fetch(:params, {})
488
+ raw = unwrap_scope(params.fetch(:params, {}), component_class)
474
489
 
475
490
  coerced = action_def.schema.coerce(raw, dropped)
476
491
  log_dropped_params(dropped, action_def.params, component_class, action_name)
477
492
  coerced
478
493
  end
479
494
 
495
+ # Issue #184: a scoped component's fields POST bracketed (todo[title]), which
496
+ # Rails expands to { "todo" => { "title" => … } } before the action runs. Peel
497
+ # exactly ONE scope level so the FLAT schema { title: :string } matches (the
498
+ # #67 bracket-drop footgun). Only when the component declares reactive_scope
499
+ # AND the raw params carry that single key mapping to a Hash — otherwise the
500
+ # raw params pass through untouched (unscoped components + nested_attributes
501
+ # shapes are unaffected).
502
+ def unwrap_scope(raw, component_class)
503
+ scope = component_class.reactive_scope if component_class.respond_to?(:reactive_scope)
504
+ return raw unless scope
505
+
506
+ # At the endpoint `raw` is ActionController::Parameters, so `raw[scope]` is
507
+ # too — NOT a Hash. Accept either shape (both answer to the schema's
508
+ # coerce): unwrap only when the scope key maps to a nested params/hash.
509
+ nested = raw[scope.to_s]
510
+ nested.is_a?(Hash) || nested.is_a?(ActionController::Parameters) ? nested : raw
511
+ end
512
+
480
513
  # ---- verbose_errors dropped-param logging --------------------------
481
514
  # ParamSchema collects the dropped entries; the controller formats the ONE
482
515
  # warn line (with the #16/#21 shape hints). Everything below runs ONLY when
@@ -564,7 +597,7 @@ module Phlex
564
597
  diagnostic: :unknown_class
565
598
  )
566
599
  end
567
- unless klass.respond_to?(:reactive_action?) && klass.include?(Phlex::Reactive::Component)
600
+ unless klass.respond_to?(:reactive_actions) && klass.include?(Phlex::Reactive::Component)
568
601
  raise Phlex::Reactive::InvalidToken.new(
569
602
  "#{name} resolved but does not include Phlex::Reactive::Component",
570
603
  diagnostic: :not_reactive_class
@@ -0,0 +1,52 @@
1
+ // The client-side confirm-predicate registry — the multi-field escape hatch for
2
+ // conditional confirmation (issue #179).
3
+ //
4
+ // confirm: { when: { total: 0 }, message: } handles the single-field 80% case
5
+ // declaratively (the reactive_show conditions language), with NO JS. But some
6
+ // soft-validation is multi-field — "warn if the end date precedes the start",
7
+ // "warn if two related totals disagree" — which a single field=value condition
8
+ // can't express. This registry is the seam for that logic: name a pure function
9
+ // with `confirm: { predicate: "name", message: }` (Ruby) and register it here.
10
+ //
11
+ // The seam mirrors compute.js (setComputeReducer) and confirm.js: a settable
12
+ // registry with a lookup the controller calls. Register once at boot:
13
+ //
14
+ // import { setConfirmPredicate } from "phlex/reactive/confirm_predicate"
15
+ // setConfirmPredicate("end_before_start", ({ starts_at, ends_at }) =>
16
+ // ends_at !== "" && ends_at < starts_at)
17
+ //
18
+ // The predicate's signature is (fields) => boolean:
19
+ //
20
+ // fields — a plain object of { name: value } over the trigger root's collected
21
+ // controls (the SAME snapshot reactive_compute reads — #collectFields).
22
+ // Values are the raw control values (strings; a checkbox is a boolean).
23
+ //
24
+ // It returns truthy to WARN (the confirm dialog fires with the declared message)
25
+ // or falsy to PROCEED with no dialog. The predicate is soft-validation UX, NOT
26
+ // authorization: a user can bypass it (devtools, an unregistered name) and the
27
+ // action still hits the endpoint's real authorize/default-deny — never let a
28
+ // predicate stand in for a server-side check.
29
+ //
30
+ // A missing predicate (name never registered) makes the gate a NO-OP: the
31
+ // controller proceeds WITHOUT a dialog and warns, exactly like compute.js's
32
+ // unknown-reducer posture — a stale/typo'd name must not break the page or
33
+ // (worse) block a legitimate action behind a dialog that can never resolve.
34
+
35
+ const predicates = new Map()
36
+
37
+ // Register (or replace) the predicate for `key`. `fn` is
38
+ // (fields: Record<string, unknown>) => boolean — truthy warns, falsy proceeds.
39
+ export function setConfirmPredicate(key, fn) {
40
+ predicates.set(key, fn)
41
+ }
42
+
43
+ // Look up a registered predicate; undefined when none — the controller then
44
+ // proceeds without a dialog (and warns) rather than throwing or blocking.
45
+ export function confirmPredicate(key) {
46
+ return predicates.get(key)
47
+ }
48
+
49
+ // Test seam: clear the registry so a predicate registered in one test can't leak.
50
+ export function __resetConfirmPredicateRegistryForTest() {
51
+ predicates.clear()
52
+ }
@@ -0,0 +1,4 @@
1
+ var j=new Map;function u(b,q){j.set(b,q)}function v(b){return j.get(b)}function w(){j.clear()}export{u as setConfirmPredicate,v as confirmPredicate,w as __resetConfirmPredicateRegistryForTest};
2
+
3
+ //# debugId=2F392605F11792F264756E2164756E21
4
+ //# sourceMappingURL=confirm_predicate.min.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["confirm_predicate.js"],
4
+ "sourcesContent": [
5
+ "// The client-side confirm-predicate registry — the multi-field escape hatch for\n// conditional confirmation (issue #179).\n//\n// confirm: { when: { total: 0 }, message: } handles the single-field 80% case\n// declaratively (the reactive_show conditions language), with NO JS. But some\n// soft-validation is multi-field — \"warn if the end date precedes the start\",\n// \"warn if two related totals disagree\" — which a single field=value condition\n// can't express. This registry is the seam for that logic: name a pure function\n// with `confirm: { predicate: \"name\", message: }` (Ruby) and register it here.\n//\n// The seam mirrors compute.js (setComputeReducer) and confirm.js: a settable\n// registry with a lookup the controller calls. Register once at boot:\n//\n// import { setConfirmPredicate } from \"phlex/reactive/confirm_predicate\"\n// setConfirmPredicate(\"end_before_start\", ({ starts_at, ends_at }) =>\n// ends_at !== \"\" && ends_at < starts_at)\n//\n// The predicate's signature is (fields) => boolean:\n//\n// fields — a plain object of { name: value } over the trigger root's collected\n// controls (the SAME snapshot reactive_compute reads — #collectFields).\n// Values are the raw control values (strings; a checkbox is a boolean).\n//\n// It returns truthy to WARN (the confirm dialog fires with the declared message)\n// or falsy to PROCEED with no dialog. The predicate is soft-validation UX, NOT\n// authorization: a user can bypass it (devtools, an unregistered name) and the\n// action still hits the endpoint's real authorize/default-deny — never let a\n// predicate stand in for a server-side check.\n//\n// A missing predicate (name never registered) makes the gate a NO-OP: the\n// controller proceeds WITHOUT a dialog and warns, exactly like compute.js's\n// unknown-reducer posture — a stale/typo'd name must not break the page or\n// (worse) block a legitimate action behind a dialog that can never resolve.\n\nconst predicates = new Map()\n\n// Register (or replace) the predicate for `key`. `fn` is\n// (fields: Record<string, unknown>) => boolean — truthy warns, falsy proceeds.\nexport function setConfirmPredicate(key, fn) {\n predicates.set(key, fn)\n}\n\n// Look up a registered predicate; undefined when none — the controller then\n// proceeds without a dialog (and warns) rather than throwing or blocking.\nexport function confirmPredicate(key) {\n return predicates.get(key)\n}\n\n// Test seam: clear the registry so a predicate registered in one test can't leak.\nexport function __resetConfirmPredicateRegistryForTest() {\n predicates.clear()\n}\n"
6
+ ],
7
+ "mappings": "AAkCA,IAAM,EAAa,IAAI,IAIhB,SAAS,CAAmB,CAAC,EAAK,EAAI,CAC3C,EAAW,IAAI,EAAK,CAAE,EAKjB,SAAS,CAAgB,CAAC,EAAK,CACpC,OAAO,EAAW,IAAI,CAAG,EAIpB,SAAS,CAAsC,EAAG,CACvD,EAAW,MAAM",
8
+ "debugId": "2F392605F11792F264756E2164756E21",
9
+ "names": []
10
+ }