phlex-reactive 0.4.8 → 0.9.1
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 +924 -4
- data/README.md +986 -32
- data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
- data/app/javascript/phlex/reactive/compute.js +52 -7
- data/app/javascript/phlex/reactive/compute.min.js +4 -0
- data/app/javascript/phlex/reactive/compute.min.js.map +10 -0
- data/app/javascript/phlex/reactive/confirm.min.js +4 -0
- data/app/javascript/phlex/reactive/confirm.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +1487 -80
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -0
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -0
- data/lib/generators/phlex/reactive/component/USAGE +2 -1
- data/lib/generators/phlex/reactive/component/templates/component.rb.erb +1 -2
- data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +33 -2
- data/lib/generators/phlex/reactive/install/install_generator.rb +9 -3
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +37 -0
- data/lib/phlex/reactive/component/dsl.rb +249 -0
- data/lib/phlex/reactive/component/helpers.rb +595 -0
- data/lib/phlex/reactive/component/identity.rb +92 -0
- data/lib/phlex/reactive/component/registry.rb +200 -0
- data/lib/phlex/reactive/component.rb +30 -442
- data/lib/phlex/reactive/doctor.rb +333 -0
- data/lib/phlex/reactive/engine.rb +27 -9
- data/lib/phlex/reactive/js.rb +249 -0
- data/lib/phlex/reactive/log_subscriber.rb +64 -0
- data/lib/phlex/reactive/param_schema.rb +390 -0
- data/lib/phlex/reactive/reply.rb +5 -3
- data/lib/phlex/reactive/response.rb +160 -16
- data/lib/phlex/reactive/stream.rb +98 -0
- data/lib/phlex/reactive/streamable.rb +307 -43
- data/lib/phlex/reactive/test_helpers/matchers.rb +112 -0
- data/lib/phlex/reactive/test_helpers.rb +308 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +333 -14
- data/lib/tasks/phlex_reactive.rake +14 -0
- metadata +19 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/log_subscriber"
|
|
4
|
+
|
|
5
|
+
module Phlex
|
|
6
|
+
module Reactive
|
|
7
|
+
# Opt-in LogSubscriber (issue #107): turns each hot-path event into ONE
|
|
8
|
+
# compact debug line, so a developer can watch reactive traffic in the log
|
|
9
|
+
# without an APM:
|
|
10
|
+
#
|
|
11
|
+
# [reactive] Counter#increment ok (3.1ms)
|
|
12
|
+
# [reactive] Counter#drop_table denied_undeclared (0.2ms)
|
|
13
|
+
# [reactive] render Counter 512B (0.9ms)
|
|
14
|
+
# [reactive] broadcast replace Counter →2 (1.4ms)
|
|
15
|
+
#
|
|
16
|
+
# Default OFF — the events fire for APMs regardless of this; attaching the
|
|
17
|
+
# subscriber only adds the gem's own log lines. The engine attaches it once
|
|
18
|
+
# at boot when Phlex::Reactive.log_events is true. Lines are logged at DEBUG,
|
|
19
|
+
# so they're invisible unless the app's log level allows it.
|
|
20
|
+
#
|
|
21
|
+
# Payloads carry NAMES/outcome/sizes ONLY (never token/params/state), so
|
|
22
|
+
# these lines can never leak a secret. A :invalid_token event has no trusted
|
|
23
|
+
# component name (the token didn't verify), so the line omits the `Class#`
|
|
24
|
+
# prefix rather than echo an unverified class.
|
|
25
|
+
class LogSubscriber < ::ActiveSupport::LogSubscriber
|
|
26
|
+
def action(event)
|
|
27
|
+
return unless logger.debug?
|
|
28
|
+
|
|
29
|
+
payload = event.payload
|
|
30
|
+
subject =
|
|
31
|
+
if payload[:component]
|
|
32
|
+
"#{payload[:component]}##{payload[:action]}"
|
|
33
|
+
else
|
|
34
|
+
# No trusted component (invalid token) — name the action alone.
|
|
35
|
+
payload[:action]
|
|
36
|
+
end
|
|
37
|
+
debug { "[reactive] #{subject} #{payload[:outcome]} (#{round(event.duration)}ms)" }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def render(event)
|
|
41
|
+
return unless logger.debug?
|
|
42
|
+
|
|
43
|
+
payload = event.payload
|
|
44
|
+
debug { "[reactive] render #{payload[:component]} #{payload[:bytesize]}B (#{round(event.duration)}ms)" }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def broadcast(event)
|
|
48
|
+
return unless logger.debug?
|
|
49
|
+
|
|
50
|
+
payload = event.payload
|
|
51
|
+
debug do
|
|
52
|
+
"[reactive] broadcast #{payload[:stream_action]} #{payload[:component]} " \
|
|
53
|
+
"→#{payload[:streamables]} (#{round(event.duration)}ms)"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def round(duration_ms)
|
|
60
|
+
duration_ms.round(1)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
require "bigdecimal"
|
|
6
|
+
require "active_model"
|
|
7
|
+
require "active_model/type"
|
|
8
|
+
|
|
9
|
+
module Phlex
|
|
10
|
+
module Reactive
|
|
11
|
+
# A COMPILED param schema (issue #109). The coerce family used to live inline
|
|
12
|
+
# in ActionsController; here it's a standalone unit built ONCE at declaration
|
|
13
|
+
# (Component.action -> ParamSchema.compile) so:
|
|
14
|
+
#
|
|
15
|
+
# * an unknown type symbol raises Phlex::Reactive::UnknownParamType at class
|
|
16
|
+
# load instead of silently `to_s`-ing at request time, and
|
|
17
|
+
# * the drop-don't-fabricate coercion is unit-testable without a booted
|
|
18
|
+
# request spec.
|
|
19
|
+
#
|
|
20
|
+
# The behavior is byte-for-byte what the controller did: the built-ins keep
|
|
21
|
+
# their exact semantics ("abc".to_i => 0, not DROP; :file duck-type DROP; the
|
|
22
|
+
# array/hash DROP rules), the bracket-expansion/deep-merge matrix (#16/#21/
|
|
23
|
+
# #24/#39) is UNCHANGED, and the verbose_errors dropped-param collector (#82/
|
|
24
|
+
# #87) threads through unchanged — a nil collector is the zero-cost prod path.
|
|
25
|
+
#
|
|
26
|
+
# A type is one of:
|
|
27
|
+
# * a scalar symbol (:string/:integer/:float/:boolean/:file/:date/
|
|
28
|
+
# :datetime/:decimal, or an app-registered type)
|
|
29
|
+
# * a Hash schema ({ id: :integer, ... }) — nested object
|
|
30
|
+
# * a one-element Array ([:integer] / [{ ... }]) — array of that
|
|
31
|
+
class ParamSchema
|
|
32
|
+
# Sentinel: a declared key whose value can't be coerced to its type is
|
|
33
|
+
# DROPPED (not assigned), so the method's keyword default applies — exactly
|
|
34
|
+
# as if the client had omitted the key. Distinct from a coerced nil/[]. A
|
|
35
|
+
# custom param_type callable returns this to reject a value while keeping
|
|
36
|
+
# the drop-don't-fabricate contract, so it is PUBLIC.
|
|
37
|
+
DROP = Object.new
|
|
38
|
+
DROP.freeze
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# Validate `schema` recursively against the param-type registry and
|
|
42
|
+
# return a compiled ParamSchema. Raises UnknownParamType at the FIRST
|
|
43
|
+
# unknown type symbol (naming the full bracketed path), so a typo fails
|
|
44
|
+
# loudly at declaration. A blank schema compiles to an empty schema (the
|
|
45
|
+
# action drops everything the client posts).
|
|
46
|
+
def compile(schema)
|
|
47
|
+
schema = {} if schema.nil?
|
|
48
|
+
validate!(schema, nil)
|
|
49
|
+
new(schema)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The frozen-semantics built-in registry, rebuilt on demand (the module
|
|
53
|
+
# dups it so app registration doesn't mutate this template). Each entry
|
|
54
|
+
# is a callable(value) -> coerced | DROP. The scalar casts here are the
|
|
55
|
+
# EXACT ones the controller ran, so the existing request-spec matrix is
|
|
56
|
+
# unchanged.
|
|
57
|
+
# rubocop:disable Style/ItBlockParameter, Style/SymbolProc
|
|
58
|
+
# Explicit `(value)` params, NOT `it` or `&:to_s`: the date/datetime/decimal
|
|
59
|
+
# casters wrap an INNER `parse_or_drop { ... }` block, and `it` there would
|
|
60
|
+
# bind to that inner block's (absent) param, not the caster's value — the
|
|
61
|
+
# #109 autocorrect trap. Keep the WHOLE family explicit so it reads
|
|
62
|
+
# uniformly and no caster can be collapsed to a zero-arity lambda.
|
|
63
|
+
def built_in_types
|
|
64
|
+
{
|
|
65
|
+
string: ->(value) { value.to_s },
|
|
66
|
+
integer: ->(value) { value.to_i },
|
|
67
|
+
float: ->(value) { value.to_f },
|
|
68
|
+
boolean: ->(value) { BOOLEAN_TYPE.cast(value) },
|
|
69
|
+
# :file and the composite types (Hash/Array) are handled structurally
|
|
70
|
+
# in #coerce, not via a scalar callable — the registry entry exists so
|
|
71
|
+
# compile-time validation recognizes the symbol. A nil callable means
|
|
72
|
+
# "handled structurally"; #coerce never dispatches it here.
|
|
73
|
+
file: nil,
|
|
74
|
+
date: ->(value) { parse_or_drop { Date.iso8601(value.to_s) } },
|
|
75
|
+
datetime: ->(value) { parse_or_drop { DateTime.iso8601(value.to_s) } },
|
|
76
|
+
decimal: ->(value) { parse_or_drop { BigDecimal(value.to_s) } }
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
# rubocop:enable Style/ItBlockParameter, Style/SymbolProc
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Run a parse that raises on bad input (Date/DateTime iso8601, BigDecimal)
|
|
84
|
+
# and turn the failure into DROP — the keyword default then applies rather
|
|
85
|
+
# than a fabricated value. ::ArgumentError covers BigDecimal("abc") and a
|
|
86
|
+
# blank/garbage string; ::Date::Error (an ArgumentError subclass) covers
|
|
87
|
+
# the iso8601 parsers; ::TypeError covers a nil slipping through.
|
|
88
|
+
#
|
|
89
|
+
# MUST be top-level-qualified (::): the phlex gem defines Phlex::Argument
|
|
90
|
+
# Error (a subclass of the stdlib one), and this file is nested under
|
|
91
|
+
# Phlex::Reactive, so a bare `ArgumentError` resolves LEXICALLY to
|
|
92
|
+
# Phlex::ArgumentError — which would NOT catch a stdlib Date::Error and the
|
|
93
|
+
# parse failure would escape as a 500.
|
|
94
|
+
def parse_or_drop
|
|
95
|
+
yield
|
|
96
|
+
rescue ::ArgumentError, ::TypeError
|
|
97
|
+
DROP
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Walk the schema, validating each type symbol against the registry and
|
|
101
|
+
# recursing into nested hashes and array element types. `path` is the
|
|
102
|
+
# bracketed name for a helpful error message.
|
|
103
|
+
def validate!(schema, path)
|
|
104
|
+
schema.each do |key, type|
|
|
105
|
+
validate_type!(type, join(path, key))
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_type!(type, path)
|
|
110
|
+
case type
|
|
111
|
+
when Array
|
|
112
|
+
unless type.length == 1
|
|
113
|
+
raise UnknownParamType, "#{path}: an array type must wrap exactly one element type, " \
|
|
114
|
+
"got #{type.inspect}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
validate_type!(type.first, "#{path}[]")
|
|
118
|
+
when Hash
|
|
119
|
+
validate!(type, path)
|
|
120
|
+
when Symbol
|
|
121
|
+
unless Phlex::Reactive.param_type?(type)
|
|
122
|
+
raise UnknownParamType, "#{path}: unknown param type #{type.inspect}. Known types: " \
|
|
123
|
+
"#{Phlex::Reactive.param_types.keys.sort.join(", ")}. " \
|
|
124
|
+
"Register a custom type with Phlex::Reactive.param_type(#{type.inspect}) { |v| ... }."
|
|
125
|
+
end
|
|
126
|
+
else
|
|
127
|
+
raise UnknownParamType, "#{path}: a param type must be a Symbol, a Hash schema, or a " \
|
|
128
|
+
"one-element Array, got #{type.inspect}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def join(path, key)
|
|
133
|
+
path ? "#{path}[#{key}]" : key.to_s
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# ActiveModel's Boolean type, instantiated once — the exact caster the
|
|
138
|
+
# controller used (an unrecognized value casts to nil, which is KEPT, not
|
|
139
|
+
# dropped).
|
|
140
|
+
BOOLEAN_TYPE = ActiveModel::Type::Boolean.new
|
|
141
|
+
private_constant :BOOLEAN_TYPE
|
|
142
|
+
|
|
143
|
+
attr_reader :schema
|
|
144
|
+
|
|
145
|
+
def initialize(schema)
|
|
146
|
+
@schema = schema
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Coerce client params against this schema. Anything not in the schema is
|
|
150
|
+
# dropped — no raw mass assignment reaches the component. The top-level
|
|
151
|
+
# params (an ActionController::Parameters or a plain Hash) coerce against
|
|
152
|
+
# the hash schema (same recursion as nested hashes).
|
|
153
|
+
#
|
|
154
|
+
# `dropped` is the verbose_errors diagnostics collector: an Array that
|
|
155
|
+
# accumulates [bracketed_path, :undeclared|:uncoercible] entries. Pass nil
|
|
156
|
+
# (the production path) and every diagnostic branch early-returns — zero
|
|
157
|
+
# extra work.
|
|
158
|
+
def coerce(params, dropped = nil)
|
|
159
|
+
# A schema-less action drops EVERYTHING the client posted. Under a
|
|
160
|
+
# verbose collector, record every posted key as :undeclared (forgetting
|
|
161
|
+
# `params:` entirely is the loudest silent drop — #87); otherwise it's a
|
|
162
|
+
# cost-free {} return on the production path.
|
|
163
|
+
if @schema.empty?
|
|
164
|
+
collect_undeclared(dropped, to_param_hash(params), {}, nil) if dropped
|
|
165
|
+
return {}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The TOP-LEVEL params container is the request's `params[:params]` — a
|
|
169
|
+
# coerced-away malformed top level (a stray non-hash) is normalized back
|
|
170
|
+
# to {} here, never DROP: the whole point of the top level is to hold the
|
|
171
|
+
# coerced kwargs, so a bad container yields "no params", not a dropped
|
|
172
|
+
# action argument. Nested malformed hashes DO drop (see coerce_hash).
|
|
173
|
+
coerced = coerce_hash(params, @schema, dropped, nil)
|
|
174
|
+
coerced.equal?(DROP) ? {} : coerced
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
# Coerce a value against a declared type. Arrays accept both a real JSON
|
|
180
|
+
# array and a Rails-style index hash ({ "0" => ..., "1" => ... }), so a
|
|
181
|
+
# fields_for collection works either way.
|
|
182
|
+
def coerce_value(value, type, dropped, path)
|
|
183
|
+
case type
|
|
184
|
+
when Array
|
|
185
|
+
coerce_array(value, type.first, dropped, path)
|
|
186
|
+
when Hash
|
|
187
|
+
coerce_hash(value, type, dropped, path)
|
|
188
|
+
when :file
|
|
189
|
+
coerce_file(value)
|
|
190
|
+
else
|
|
191
|
+
coerce_scalar(value, type)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Dispatch a scalar through the registry callable. The symbol is known
|
|
196
|
+
# (compile validated it), so the lookup always hits.
|
|
197
|
+
def coerce_scalar(value, type)
|
|
198
|
+
Phlex::Reactive.param_types.fetch(type).call(value)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# An uploaded file (issue #34) passes through UNTOUCHED — never .to_s'd,
|
|
202
|
+
# which would corrupt it. Anything that isn't an uploaded file returns DROP
|
|
203
|
+
# (the keyword default applies). Duck-types on UploadedFile's interface
|
|
204
|
+
# rather than naming a class, so a Rack::Test upload, an ActionDispatch
|
|
205
|
+
# upload, and a Falcon multipart body all qualify.
|
|
206
|
+
def coerce_file(value)
|
|
207
|
+
uploaded_file?(value) ? value : DROP
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def uploaded_file?(value)
|
|
211
|
+
value.respond_to?(:original_filename) && value.respond_to?(:read)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# A real array (or Rails index hash) coerces element-wise. A malformed
|
|
215
|
+
# present-but-non-array value returns DROP rather than [] — coercing a stray
|
|
216
|
+
# scalar to an empty array would let a bad payload read as an explicit
|
|
217
|
+
# "clear everything". An ELEMENT that coerces to DROP is rejected; an array
|
|
218
|
+
# whose every element drops returns DROP (keyword default applies), but a
|
|
219
|
+
# genuinely empty input array stays [].
|
|
220
|
+
def coerce_array(value, element_type, dropped, path)
|
|
221
|
+
values = array_values(value)
|
|
222
|
+
return DROP if values.nil?
|
|
223
|
+
return [] if values.empty?
|
|
224
|
+
|
|
225
|
+
coerced =
|
|
226
|
+
if dropped
|
|
227
|
+
values.map.with_index do |element, i|
|
|
228
|
+
element_path = "#{path}[#{i}]"
|
|
229
|
+
result = coerce_value(element, element_type, dropped, element_path)
|
|
230
|
+
dropped << [element_path, :uncoercible] if result.equal?(DROP)
|
|
231
|
+
result
|
|
232
|
+
end
|
|
233
|
+
else
|
|
234
|
+
values.map { coerce_value(it, element_type, nil, nil) }
|
|
235
|
+
end
|
|
236
|
+
coerced.reject! { it.equal?(DROP) }
|
|
237
|
+
coerced.empty? ? DROP : coerced
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Keep declared keys only (drop undeclared — no mass assignment), recursing
|
|
241
|
+
# for nested hash/array element types. Symbolizes keys to splat as kwargs.
|
|
242
|
+
# A key whose value coerces to DROP is skipped (keyword default applies).
|
|
243
|
+
#
|
|
244
|
+
# A present-but-malformed value (a nested `invoice: null` / `invoice: "x"`
|
|
245
|
+
# for a hash schema) returns DROP rather than fabricating {} — the same
|
|
246
|
+
# drop-don't-fabricate rule coerce_array applies to a non-array, so a bad
|
|
247
|
+
# payload can't read as an explicit empty object on update!(nested:). The
|
|
248
|
+
# top-level entry (coerce) normalizes that DROP back to {}. A genuinely
|
|
249
|
+
# empty hash ({} / an empty index form) stays {}.
|
|
250
|
+
def coerce_hash(value, schema, dropped, path)
|
|
251
|
+
return DROP unless hash_like?(value)
|
|
252
|
+
|
|
253
|
+
hash = to_param_hash(value)
|
|
254
|
+
collect_undeclared(dropped, hash, schema, path) if dropped
|
|
255
|
+
schema.each_with_object({}) do |(key, type), out|
|
|
256
|
+
key_name = key.to_s
|
|
257
|
+
next unless hash.key?(key_name)
|
|
258
|
+
|
|
259
|
+
key_path = dropped && join_path(path, key_name)
|
|
260
|
+
coerced = coerce_value(hash[key_name], type, dropped, key_path)
|
|
261
|
+
if coerced.equal?(DROP)
|
|
262
|
+
dropped << [key_path, :uncoercible] if dropped
|
|
263
|
+
next
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
out[key.to_sym] = coerced
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# ---- verbose_errors dropped-param diagnostics ----------------------
|
|
271
|
+
# Everything below runs ONLY when the collector exists (flag on); the
|
|
272
|
+
# production path never reaches these methods.
|
|
273
|
+
|
|
274
|
+
def join_path(path, key)
|
|
275
|
+
path ? "#{path}[#{key}]" : key
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Record every input key this schema level doesn't declare. A hash value
|
|
279
|
+
# expands to its leaf paths so the log shows the full bracketed name the
|
|
280
|
+
# client posted (invoice[date], not just invoice).
|
|
281
|
+
def collect_undeclared(dropped, hash, schema, path)
|
|
282
|
+
declared = schema.keys.map(&:to_s)
|
|
283
|
+
hash.each do |key, value|
|
|
284
|
+
next if declared.include?(key)
|
|
285
|
+
|
|
286
|
+
record_undeclared(dropped, join_path(path, key), value)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def record_undeclared(dropped, path, value)
|
|
291
|
+
if value.is_a?(Hash) && value.any?
|
|
292
|
+
value.each { |key, child| record_undeclared(dropped, "#{path}[#{key}]", child) }
|
|
293
|
+
else
|
|
294
|
+
dropped << [path, :undeclared]
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# ---- end verbose_errors diagnostics --------------------------------
|
|
299
|
+
|
|
300
|
+
# Normalize an array param: a real array passes through; a Rails index hash
|
|
301
|
+
# ({ "0" => ..., "1" => ... }) becomes its values in index order. Anything
|
|
302
|
+
# else (a stray scalar, nil) is malformed => nil, so the caller drops the
|
|
303
|
+
# param rather than fabricating an empty array.
|
|
304
|
+
def array_values(value)
|
|
305
|
+
return value.to_a if value.is_a?(Array)
|
|
306
|
+
|
|
307
|
+
return unless value.respond_to?(:to_unsafe_h) || value.is_a?(Hash)
|
|
308
|
+
|
|
309
|
+
to_param_hash(value).sort_by { |k, _| k.to_i }.map(&:last)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# True when `value` is a Hash or an ActionController::Parameters — i.e. a
|
|
313
|
+
# value to_param_hash unwraps to real keys rather than {}. A nested hash
|
|
314
|
+
# schema fed anything else (nil, a scalar) is malformed and drops. The
|
|
315
|
+
# SAME acceptance test to_param_hash uses, kept as a guard so a malformed
|
|
316
|
+
# input is dropped BEFORE to_param_hash flattens it to a fabricated {}.
|
|
317
|
+
def hash_like?(value)
|
|
318
|
+
value.is_a?(Hash) || value.respond_to?(:to_unsafe_h)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Unwrap ActionController::Parameters (or a plain Hash) to a string-keyed
|
|
322
|
+
# Hash so coercion can index it uniformly, then expand bracket notation so
|
|
323
|
+
# a model-scoped Rails form's flat keys nest (issue #21).
|
|
324
|
+
def to_param_hash(value)
|
|
325
|
+
flat =
|
|
326
|
+
if value.respond_to?(:to_unsafe_h)
|
|
327
|
+
value.to_unsafe_h.stringify_keys
|
|
328
|
+
elsif value.is_a?(Hash)
|
|
329
|
+
value.stringify_keys
|
|
330
|
+
else
|
|
331
|
+
return {}
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
expand_bracket_keys(flat)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# The client's #collectFields keeps a form input's name verbatim, so a
|
|
338
|
+
# Rails Form(model: @invoice) posts FLAT bracketed keys like
|
|
339
|
+
# "invoice[date]". Coercion does exact-key matching, so without this a
|
|
340
|
+
# nested schema (params: { invoice: { date: … } }) never finds "invoice"
|
|
341
|
+
# and drops everything. Expand "invoice[date]" => "2026-01-02" into
|
|
342
|
+
# { "invoice" => { "date" => "2026-01-02" } } — and "items[0][qty]" into
|
|
343
|
+
# the Rails index-hash form coerce_array already understands — deep-merging
|
|
344
|
+
# so sibling keys (invoice[date], invoice[status]) coalesce. Keys WITHOUT
|
|
345
|
+
# brackets and already-nested values pass through untouched.
|
|
346
|
+
def expand_bracket_keys(flat)
|
|
347
|
+
flat.each_with_object({}) do |(key, value), out|
|
|
348
|
+
deep_assign(out, bracket_path(key), value)
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Matches each bracket segment in "items_attributes][0][qty]" — the part
|
|
353
|
+
# after the first "[". Hoisted to a frozen constant so coercing a bracketed
|
|
354
|
+
# key doesn't recompile the pattern per key on every request.
|
|
355
|
+
BRACKET_SEGMENT = /[^\[\]]+/
|
|
356
|
+
private_constant :BRACKET_SEGMENT
|
|
357
|
+
|
|
358
|
+
# "invoice[items_attributes][0][qty]" => ["invoice", "items_attributes",
|
|
359
|
+
# "0", "qty"]. A key with no brackets is a single-element path.
|
|
360
|
+
def bracket_path(key)
|
|
361
|
+
return [key] unless key.include?("[")
|
|
362
|
+
|
|
363
|
+
head, rest = key.split("[", 2)
|
|
364
|
+
[head, *rest.scan(BRACKET_SEGMENT)]
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Walk/create nested hashes along `path`, then merge `value` at the leaf so
|
|
368
|
+
# a bracket key and a sibling pre-nested object coalesce regardless of which
|
|
369
|
+
# arrives first. #merge_value deep-merges hash/hash collisions and otherwise
|
|
370
|
+
# lets the later value win.
|
|
371
|
+
def deep_assign(hash, path, value)
|
|
372
|
+
*parents, leaf = path
|
|
373
|
+
node = parents.reduce(hash) do |acc, segment|
|
|
374
|
+
acc[segment] = {} unless acc[segment].is_a?(Hash)
|
|
375
|
+
acc[segment]
|
|
376
|
+
end
|
|
377
|
+
node[leaf] = merge_value(node[leaf], value)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Combine an existing leaf value with a new one. Two hashes deep-merge (so
|
|
381
|
+
# bracket-expanded fields and a pre-nested object for the same key both
|
|
382
|
+
# survive); any other collision takes the new value.
|
|
383
|
+
def merge_value(existing, value)
|
|
384
|
+
return value unless existing.is_a?(Hash) && value.is_a?(Hash)
|
|
385
|
+
|
|
386
|
+
existing.merge(value.stringify_keys) { |_k, old, new| merge_value(old, new) }
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
data/lib/phlex/reactive/reply.rb
CHANGED
|
@@ -48,9 +48,11 @@ module Phlex
|
|
|
48
48
|
Response.morph(@component)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
#
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
# Update only inner HTML (preserves the root element + its token attr).
|
|
52
|
+
# `morph: true` morphs the inner HTML in place (issue #113) so a
|
|
53
|
+
# cross-tab/per-field update keeps a focused input's caret.
|
|
54
|
+
def update(morph: false)
|
|
55
|
+
Response.update(@component, morph:)
|
|
54
56
|
end
|
|
55
57
|
|
|
56
58
|
# Reactive collections (issue #35) — add/remove a row in a declared
|