phlex-reactive 0.12.5 → 0.12.6

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.
@@ -1084,6 +1084,91 @@ module Phlex
1084
1084
  { data: { reactive_show_targets: normalized.to_json } }
1085
1085
  end
1086
1086
 
1087
+ # Client-only localStorage draft over the fields this root OWNS (issue
1088
+ # #239) — "don't make me start over". Spread on the ROOT (mix with
1089
+ # reactive_root, like reactive_show_targets); the generic controller
1090
+ # then writes every owned control's value to localStorage as the user
1091
+ # types (debounced on `input`, immediate on `change`, flushed on
1092
+ # disconnect), restores the draft on the NEXT connect, and forgets it
1093
+ # when the owning form submits successfully (turbo:submit-end), when
1094
+ # `ttl` elapses, or on a js.persist_clear op:
1095
+ #
1096
+ # div(**mix(reactive_root, reactive_persist(key: "village-apply", ttl: 7.days))) do
1097
+ # input(**reactive_field(:name)) # persisted
1098
+ # input(name: "fuckery", **reactive_persist_skip) # honeypot — never
1099
+ # input(type: "hidden", name: "tz") # hidden — never (default)
1100
+ # end
1101
+ #
1102
+ # Never persisted: type=hidden/file/password/submit/button/reset/image,
1103
+ # anything carrying reactive_persist_skip, a nested reactive root's
1104
+ # controls (#15 ownership), and — when `fields:` narrows the set —
1105
+ # any name outside it (scope-aware symbols, the reactive_show form).
1106
+ # `autocomplete="off"` is NOT an implicit skip: honeypots (an
1107
+ # invisible_captcha text input looks like any other) must opt out
1108
+ # explicitly or sit outside the root.
1109
+ #
1110
+ # `restore:` — `:blank` (default) restores a draft value only into a
1111
+ # control the server rendered BLANK, so a 422 re-render's submitted
1112
+ # values win over an older draft; `:always` lets the draft win.
1113
+ #
1114
+ # Same posture as reactive_show: no token, no POST, no expression
1115
+ # surface. Stored values are plain user input replayed via
1116
+ # .value/.checked (never HTML) — a tampered draft can only fill what
1117
+ # the user could type. PII sits in localStorage for `ttl`; the
1118
+ # successful-submit clear and `ttl` are the shared-computer mitigation
1119
+ # (see docs/security). ONE call per root — Phlex `mix` space-joins
1120
+ # duplicate string data values, so a second call would corrupt the JSON.
1121
+ def reactive_persist(key:, ttl: 7.days, fields: nil, restore: :blank, debounce: 300)
1122
+ payload = {
1123
+ "key" => validate_persist_key!(key),
1124
+ "ttl" => validate_persist_ttl!(ttl),
1125
+ "debounce" => validate_persist_debounce!(debounce)
1126
+ }
1127
+ payload["restore"] = "always" if validate_persist_restore!(restore) == :always
1128
+ payload["fields"] = validate_persist_fields!(fields) if fields
1129
+
1130
+ { data: { reactive_persist: payload.to_json } }
1131
+ end
1132
+
1133
+ # Mark ONE control as never persisted (issue #239) — a honeypot, a
1134
+ # one-time code. Spread on the control: input(name: "x", **reactive_persist_skip).
1135
+ def reactive_persist_skip
1136
+ { data: { reactive_persist: "off" } }
1137
+ end
1138
+
1139
+ def validate_persist_key!(key)
1140
+ return key if key.is_a?(String) && !key.strip.empty?
1141
+
1142
+ raise ArgumentError, "reactive_persist key: must be a non-blank String, got #{key.inspect}"
1143
+ end
1144
+
1145
+ # Seconds on the wire: an ActiveSupport::Duration (7.days) or an Integer.
1146
+ def validate_persist_ttl!(ttl)
1147
+ seconds = ttl.is_a?(ActiveSupport::Duration) ? ttl.to_i : ttl
1148
+ return seconds if seconds.is_a?(Integer) && seconds.positive?
1149
+
1150
+ raise ArgumentError, "reactive_persist ttl: must be a positive duration or Integer seconds, got #{ttl.inspect}"
1151
+ end
1152
+
1153
+ def validate_persist_debounce!(debounce)
1154
+ return debounce if debounce.is_a?(Integer) && !debounce.negative?
1155
+
1156
+ raise ArgumentError, "reactive_persist debounce: must be a non-negative Integer (ms), got #{debounce.inspect}"
1157
+ end
1158
+
1159
+ def validate_persist_restore!(restore)
1160
+ return restore if %i[blank always].include?(restore)
1161
+
1162
+ raise ArgumentError, "reactive_persist restore: must be :blank or :always, got #{restore.inspect}"
1163
+ end
1164
+
1165
+ def validate_persist_fields!(fields)
1166
+ list = Array(fields)
1167
+ raise ArgumentError, "reactive_persist fields: needs at least one field name" if list.empty?
1168
+
1169
+ list.map { scoped_field_name(it) }
1170
+ end
1171
+
1087
1172
  # Scoped busy indicator (issue #99). Marks an element so the generic
1088
1173
  # controller toggles `data-reactive-busy` on it ONLY while `action` is in
1089
1174
  # flight — the scoped sibling of the always-on `data-reactive-busy` the
@@ -252,6 +252,36 @@ module Phlex
252
252
  @ops.empty?
253
253
  end
254
254
 
255
+ # --- Client-only drafts (issue #239) ---
256
+ #
257
+ # persist_state(**state) — merge a FLAT bag of scalars (a wizard's
258
+ # current step) into the root's reactive_persist draft alongside the
259
+ # owned fields' values; the client restores it on the next connect as
260
+ # data-reactive-persist-state on the root plus the
261
+ # reactive:persist-restored event's detail.state. Always targets the
262
+ # ROOT (the draft lives there). persist_clear — forget the draft now
263
+ # (the explicit sibling of the automatic turbo:submit-end clear).
264
+ # Both ACTOR-ONLY like focus/submit: a broadcast that rewrote or wiped
265
+ # every subscriber's draft would be hostile (BROADCAST_REFUSED_OPS).
266
+
267
+ def persist_state(**state)
268
+ raise ArgumentError, "#{self.class}: persist_state needs at least one key (e.g. step: 2)" if state.empty?
269
+
270
+ state.each do |name, value|
271
+ next if value.nil? || [String, Numeric, TrueClass, FalseClass].any? { value.is_a?(it) }
272
+
273
+ raise ArgumentError,
274
+ "#{self.class}: persist_state values must be scalar (String/Numeric/true/false/nil) — " \
275
+ "#{name.inspect} is #{value.class} (the draft stays flat)"
276
+ end
277
+
278
+ append("persist_state", { "to" => ROOT_SENTINEL, "state" => state.transform_keys(&:to_s).freeze }.freeze)
279
+ end
280
+
281
+ def persist_clear
282
+ append("persist_clear", { "to" => ROOT_SENTINEL }.freeze)
283
+ end
284
+
255
285
  private
256
286
 
257
287
  # Immutability: every verb funnels here and returns a NEW frozen chain —
@@ -39,7 +39,7 @@ module Phlex
39
39
  # (issue #228) would read every subscriber's clipboard. They belong to
40
40
  # the actor's own reply (reply.js) or gesture (on_client / a reducer's
41
41
  # $ops), never a broadcast. Names mirror Phlex::Reactive::JS's verbs.
42
- BROADCAST_REFUSED_OPS = %w[focus focus_first submit paste_into].freeze
42
+ BROADCAST_REFUSED_OPS = %w[focus focus_first submit paste_into persist_state persist_clear].freeze
43
43
 
44
44
  # The broadcast_to verb kwargs (issue #185) → their Turbo stream action.
45
45
  # SELF-TARGETING verbs derive the target from the component's #id (require a
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.12.5"
5
+ VERSION = "0.12.6"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-reactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.5
4
+ version: 0.12.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson