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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +79 -1
- data/app/javascript/phlex/reactive/reactive_controller.js +324 -0
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/helpers.rb +85 -0
- data/lib/phlex/reactive/js.rb +30 -0
- data/lib/phlex/reactive/streamable.rb +1 -1
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
|
@@ -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
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -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
|