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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +315 -0
- data/README.md +176 -122
- data/app/controllers/phlex/reactive/actions_controller.rb +41 -8
- data/app/javascript/phlex/reactive/confirm_predicate.js +52 -0
- data/app/javascript/phlex/reactive/confirm_predicate.min.js +4 -0
- data/app/javascript/phlex/reactive/confirm_predicate.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +475 -218
- 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/client_bindings.rb +57 -0
- data/lib/phlex/reactive/component/dsl.rb +158 -33
- data/lib/phlex/reactive/component/helpers.rb +478 -381
- data/lib/phlex/reactive/component/registry.rb +8 -1
- data/lib/phlex/reactive/component.rb +26 -12
- data/lib/phlex/reactive/defer.rb +1 -1
- data/lib/phlex/reactive/deferred_render_job.rb +2 -2
- data/lib/phlex/reactive/engine.rb +14 -0
- data/lib/phlex/reactive/js.rb +29 -15
- data/lib/phlex/reactive/reply.rb +105 -44
- data/lib/phlex/reactive/response.rb +139 -48
- data/lib/phlex/reactive/show_conditions.rb +249 -0
- data/lib/phlex/reactive/streamable.rb +244 -202
- data/lib/phlex/reactive/test_helpers.rb +10 -2
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +137 -12
- metadata +6 -1
|
@@ -10,21 +10,56 @@ module Phlex
|
|
|
10
10
|
# A Response governs ONLY the actor's HTTP reply. Cross-tab updates still go
|
|
11
11
|
# through Streamable's broadcast_*_to(..., exclude: reactive_connection_id).
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
13
|
+
# An action builds one through `reply` (issue #182 — the ONE door; the former
|
|
14
|
+
# Response.* class verbs are removed and raise a guided rewrite):
|
|
15
|
+
#
|
|
16
|
+
# reply.replace # re-render in place (the default, explicit)
|
|
17
|
+
# reply.replace.flash(:error, msg) # surface a validation error
|
|
18
|
+
# reply.replace.also(heading: @record.name) # + a companion element by id
|
|
19
|
+
# reply.remove # drop the element (e.g. moderation queue)
|
|
20
|
+
# reply.redirect(article_url(@article)) # slug changed -> Turbo.visit the new URL
|
|
21
|
+
# reply.replace.stream(Totals.update(@order)) # multi-stream
|
|
19
22
|
class Response
|
|
20
23
|
attr_reader :streams, :redirect_url, :token_component, :subject_component
|
|
21
24
|
|
|
25
|
+
# Issue #182: `reply` is the ONE documented door. Each former public class
|
|
26
|
+
# verb (Response.replace(self), …) is removed — it raises a guided
|
|
27
|
+
# ArgumentError naming the `reply.<verb>` rewrite. The builder BODIES live
|
|
28
|
+
# on as internal-use `build_*` class methods (public in Ruby terms — Reply
|
|
29
|
+
# calls them directly — but NOT part of the documented reply-facing surface;
|
|
30
|
+
# Response stays the single place that knows how to construct itself). The
|
|
31
|
+
# rewrite shown for a collection verb points at the keyword form (issue #182).
|
|
32
|
+
REMOVED_CLASS_VERBS = {
|
|
33
|
+
replace: "reply.replace",
|
|
34
|
+
morph: "reply.morph",
|
|
35
|
+
update: "reply.update",
|
|
36
|
+
remove: "reply.remove",
|
|
37
|
+
redirect: "reply.redirect(url)",
|
|
38
|
+
with: "reply.with(*streams)",
|
|
39
|
+
streams: "reply.streams(*streams)",
|
|
40
|
+
collection_append: "reply.append(model, to: :name)",
|
|
41
|
+
collection_prepend: "reply.prepend(model, to: :name)",
|
|
42
|
+
collection_remove: "reply.remove(model, from: :name)"
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
# rubocop:disable Style/ItBlockParameter -- `it` is illegal inside the
|
|
46
|
+
# define_singleton_method block (it takes ordinary |*, **| params), so the
|
|
47
|
+
# outer each_key must name its param explicitly.
|
|
48
|
+
REMOVED_CLASS_VERBS.each_key do |verb|
|
|
49
|
+
define_singleton_method(verb) do |*, **|
|
|
50
|
+
raise ArgumentError,
|
|
51
|
+
"Phlex::Reactive::Response.#{verb} was removed in issue #182 — " \
|
|
52
|
+
"return #{REMOVED_CLASS_VERBS[verb]} from the action (reply is the only door)"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
# rubocop:enable Style/ItBlockParameter
|
|
56
|
+
|
|
22
57
|
class << self
|
|
23
58
|
# Re-render the component in place (explicit form of today's default).
|
|
24
59
|
# `morph: true` morphs the subtree (preserves the focused input + caret)
|
|
25
|
-
# instead of an outerHTML swap — see .
|
|
26
|
-
def
|
|
27
|
-
new(streams: [
|
|
60
|
+
# instead of an outerHTML swap — see .build_morph (issue #28).
|
|
61
|
+
def build_replace(component, morph: false)
|
|
62
|
+
new(streams: [component.to_stream_replace(morph:)],
|
|
28
63
|
subject_component: component)
|
|
29
64
|
end
|
|
30
65
|
|
|
@@ -34,27 +69,27 @@ module Phlex
|
|
|
34
69
|
# per-field reactive editing (a "spreadsheet" grid where a debounced save
|
|
35
70
|
# fires while the user is still typing/tabbing). The morphed root still
|
|
36
71
|
# carries the fresh signed token, so the next action verifies.
|
|
37
|
-
def
|
|
72
|
+
def build_morph(component) = new(streams: [component.to_stream_replace(morph: true)], subject_component: component)
|
|
38
73
|
|
|
39
74
|
# Update only inner HTML (preserves the root element + its token attr).
|
|
40
75
|
# `morph: true` morphs the inner HTML in place (issue #113) instead of
|
|
41
76
|
# replacing it, so a cross-tab update keeps a peer's focus/caret.
|
|
42
|
-
def
|
|
77
|
+
def build_update(component, morph: false)
|
|
43
78
|
new(streams: [component.to_stream_update(morph:)], subject_component: component)
|
|
44
79
|
end
|
|
45
80
|
|
|
46
81
|
# Remove the component's element from the DOM. Uses the instance
|
|
47
82
|
# to_stream_remove (the component already knows its own #id — no
|
|
48
83
|
# class-builder reconstruction; works for record- and state-backed).
|
|
49
|
-
def
|
|
84
|
+
def build_remove(component) = new(streams: [component.to_stream_remove], render_self: false)
|
|
50
85
|
|
|
51
86
|
# Client-side full navigation (Turbo.visit). Use when the current URL
|
|
52
87
|
# is dead (slug rename) or the outcome belongs on another page. Pass a
|
|
53
88
|
# *_url (the off-request render context has no request host for *_path).
|
|
54
|
-
def
|
|
89
|
+
def build_redirect(url) = new(redirect_url: url, render_self: false)
|
|
55
90
|
|
|
56
91
|
# Escape hatch / multi-stream root: zero or more raw turbo-stream strings.
|
|
57
|
-
def
|
|
92
|
+
def build_with(*strings) = new(streams: strings.flatten)
|
|
58
93
|
|
|
59
94
|
# --- Reactive collections (issue #35) ---
|
|
60
95
|
# Add/remove a row in a declared reactive_collection, emitting the row
|
|
@@ -75,19 +110,19 @@ module Phlex
|
|
|
75
110
|
# container owns the add/remove trigger, so the endpoint appends its inert
|
|
76
111
|
# `reactive:token` stream (the same #30 machinery reply.streams uses) to roll
|
|
77
112
|
# the token forward without re-rendering the rows.
|
|
78
|
-
def
|
|
113
|
+
def build_collection_append(component, name, model, **row_kwargs)
|
|
79
114
|
definition = collection_def!(component, name)
|
|
80
|
-
new(streams: collection_add_streams(definition, component, model, :append),
|
|
115
|
+
new(streams: collection_add_streams(definition, component, model, :append, row_kwargs),
|
|
81
116
|
render_self: false, token_component: component)
|
|
82
117
|
end
|
|
83
118
|
|
|
84
|
-
def
|
|
119
|
+
def build_collection_prepend(component, name, model, **row_kwargs)
|
|
85
120
|
definition = collection_def!(component, name)
|
|
86
|
-
new(streams: collection_add_streams(definition, component, model, :prepend),
|
|
121
|
+
new(streams: collection_add_streams(definition, component, model, :prepend, row_kwargs),
|
|
87
122
|
render_self: false, token_component: component)
|
|
88
123
|
end
|
|
89
124
|
|
|
90
|
-
def
|
|
125
|
+
def build_collection_remove(component, name, model)
|
|
91
126
|
definition = collection_def!(component, name)
|
|
92
127
|
new(streams: collection_remove_streams(definition, component, model),
|
|
93
128
|
render_self: false, token_component: component)
|
|
@@ -99,15 +134,17 @@ module Phlex
|
|
|
99
134
|
# for an undeclared name (a typo'd collection should fail loudly, not
|
|
100
135
|
# silently emit an empty Response).
|
|
101
136
|
def collection_def!(component, name)
|
|
102
|
-
component.class.
|
|
137
|
+
component.class.reactive_collections[name.to_sym] ||
|
|
103
138
|
raise(Phlex::Reactive::Error, "undeclared reactive_collection :#{name} on #{component.class}")
|
|
104
139
|
end
|
|
105
140
|
|
|
106
141
|
# Row add (append/prepend) + count + empty-state clear. The empty-state is
|
|
107
142
|
# removed only when the list just crossed 0->1 (size == 1) — appending to
|
|
108
143
|
# an already-populated list leaves it untouched.
|
|
109
|
-
def collection_add_streams(definition, component, model, action)
|
|
110
|
-
|
|
144
|
+
def collection_add_streams(definition, component, model, action, row_kwargs = {})
|
|
145
|
+
# row_kwargs (issue #186) thread to the row component's init via the class
|
|
146
|
+
# stream builder's **options passthrough (ItemRow.new(model:, **row_kwargs)).
|
|
147
|
+
streams = [definition.item.public_send(action, target: definition.container, model:, **row_kwargs)]
|
|
111
148
|
append_count_stream(streams, definition, component)
|
|
112
149
|
|
|
113
150
|
size = definition.size_for(component)
|
|
@@ -162,11 +199,11 @@ module Phlex
|
|
|
162
199
|
# spreadsheet-like grid where a debounced save re-streams only a total
|
|
163
200
|
# cell and the user is still typing in a sibling field.
|
|
164
201
|
#
|
|
165
|
-
#
|
|
202
|
+
# reply.streams(Totals.update(@invoice)) # update only the totals
|
|
166
203
|
#
|
|
167
204
|
# render_self is false (we do NOT inject the full replace); the token is
|
|
168
205
|
# refreshed by the bound component's token stream instead.
|
|
169
|
-
def
|
|
206
|
+
def build_streams(component, *strings)
|
|
170
207
|
new(streams: strings.flatten, render_self: false, token_component: component)
|
|
171
208
|
end
|
|
172
209
|
|
|
@@ -194,9 +231,11 @@ module Phlex
|
|
|
194
231
|
# so dismiss_after has nowhere to hang — it wraps only string content.
|
|
195
232
|
return render_html(content) if content.is_a?(::Phlex::SGML)
|
|
196
233
|
|
|
197
|
-
|
|
198
|
-
if
|
|
199
|
-
|
|
234
|
+
builder = Phlex::Reactive.flash_component
|
|
235
|
+
if builder
|
|
236
|
+
# The app-owned callable maps (level, content) to its own component —
|
|
237
|
+
# the gem no longer guesses kwargs (issue #182).
|
|
238
|
+
html = render_html(builder.call(level, content))
|
|
200
239
|
return dismiss_after ? wrap_dismiss(html, dismiss_after) : html
|
|
201
240
|
end
|
|
202
241
|
|
|
@@ -225,6 +264,13 @@ module Phlex
|
|
|
225
264
|
data-reactive-flash-level="#{level_attr}"#{dismiss_attr(dismiss_after)}>#{body}</div>).html_safe
|
|
226
265
|
end
|
|
227
266
|
|
|
267
|
+
# Issue #182: flash rendering is an INTERNAL concern — the endpoint's
|
|
268
|
+
# error_flash path and Response#flash reach these via send. They are not
|
|
269
|
+
# part of the public reply surface. (Inside `class << self`, plain `private`
|
|
270
|
+
# marks these singleton methods private — private_class_method is for the
|
|
271
|
+
# class body, not here.)
|
|
272
|
+
private :flash_stream, :flash_html, :default_flash_html
|
|
273
|
+
|
|
228
274
|
# The self-dismiss attribute, or "" when off. The value is forced through
|
|
229
275
|
# to_i so a String/float (or an injection attempt) becomes a plain integer
|
|
230
276
|
# — the client's document-level handler reads it as a timeout in ms.
|
|
@@ -242,7 +288,7 @@ data-reactive-flash-level="#{level_attr}"#{dismiss_attr(dismiss_after)}>#{body}<
|
|
|
242
288
|
end
|
|
243
289
|
|
|
244
290
|
# Build a turbo-stream that updates an arbitrary target id with `content`
|
|
245
|
-
# (a Phlex component instance or an HTML string). Used by #
|
|
291
|
+
# (a Phlex component instance or an HTML string). Used by #also to
|
|
246
292
|
# re-render a companion element that isn't itself a Streamable component.
|
|
247
293
|
def update_stream(target, content)
|
|
248
294
|
Phlex::Reactive.stream_builder.update(target, html: render_html(content))
|
|
@@ -405,30 +451,67 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
|
|
|
405
451
|
# container, so it works for reply AND broadcast flashes (issue #100). It
|
|
406
452
|
# wraps only STRING content; a verbatim Phlex component owns its lifecycle.
|
|
407
453
|
def flash(level, content, target: Phlex::Reactive.flash_target, dismiss_after: nil)
|
|
408
|
-
stream(self.class.flash_stream
|
|
454
|
+
stream(self.class.send(:flash_stream, level, content, target:, dismiss_after:))
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Also re-render COMPANION elements alongside self (issue #182 — one door,
|
|
458
|
+
# replacing also_update + also_replace). The ARGUMENT TYPE picks the Turbo
|
|
459
|
+
# action; there is no verb to choose:
|
|
460
|
+
#
|
|
461
|
+
# .also(SummaryCard.new(order: @order)) -> REPLACE at the component's own #id
|
|
462
|
+
# .also(SummaryCard.new(order: @order), morph:) -> the same, MORPHED in place
|
|
463
|
+
# .also(page_heading: @record.name) -> UPDATE (inner HTML) of id "page_heading"
|
|
464
|
+
# .also("nav-badge" => @record.name) -> UPDATE of id "nav-badge" (dashed id)
|
|
465
|
+
#
|
|
466
|
+
# So the two shapes are:
|
|
467
|
+
#
|
|
468
|
+
# * a Streamable COMPONENT (positional) — a `replace` targeting its OWN #id
|
|
469
|
+
# (it self-targets, like every reactive component). `morph: true` morphs
|
|
470
|
+
# it in place (issue #28) when it holds focusable inputs to preserve.
|
|
471
|
+
# * target => content pairs — an `update` (inner HTML) of each id. The id
|
|
472
|
+
# need NOT be a reactive component (it's any element), so the only sound
|
|
473
|
+
# swap is its inner HTML. Content is a plain String (HTML-ESCAPED by
|
|
474
|
+
# Turbo, so a model value is safe), a Phlex component (rendered +
|
|
475
|
+
# auto-escaped), or an html_safe String for intentional raw HTML. Written
|
|
476
|
+
# brace-less as keywords or as an explicit Hash.
|
|
477
|
+
#
|
|
478
|
+
# This is NOT for collection rows — a row add/remove goes through
|
|
479
|
+
# reply.append(model, to: :name) / reply.remove(id, from: :name), which also
|
|
480
|
+
# emit the count + empty-state streams. `.also` only re-renders EXISTING
|
|
481
|
+
# companion elements.
|
|
482
|
+
#
|
|
483
|
+
# Returns a NEW Response (immutable). `companion` is EITHER a positional
|
|
484
|
+
# component OR target=>content pairs — never both (that raises). The
|
|
485
|
+
# brace-less keyword form lands in **targets; an explicit Hash lands in
|
|
486
|
+
# `companion`. `morph:` is reserved for the component form.
|
|
487
|
+
def also(companion = :__none, morph: false, **targets)
|
|
488
|
+
if companion != :__none && !targets.empty?
|
|
489
|
+
raise ArgumentError, "reply.also takes EITHER a component OR target => content pairs, not both"
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
case companion
|
|
493
|
+
when :__none
|
|
494
|
+
also_targets(targets)
|
|
495
|
+
when ::Phlex::SGML
|
|
496
|
+
stream(companion.to_stream_replace(morph:))
|
|
497
|
+
when Hash
|
|
498
|
+
also_targets(companion)
|
|
499
|
+
else
|
|
500
|
+
raise ArgumentError,
|
|
501
|
+
"reply.also expects target => content pairs or a Streamable component, got #{companion.class}"
|
|
502
|
+
end
|
|
409
503
|
end
|
|
410
504
|
|
|
411
|
-
#
|
|
412
|
-
#
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
# * a Phlex component — rendered + auto-escaped through the renderer (use
|
|
417
|
-
# this when the companion has its own markup), or an `html_safe` String
|
|
418
|
-
# for intentional raw HTML.
|
|
419
|
-
# Returns a NEW Response (immutable). The common "re-render self + N
|
|
420
|
-
# siblings" case no longer needs raw turbo_stream_builder.
|
|
421
|
-
def also_update(target, html:)
|
|
422
|
-
stream(self.class.update_stream(target, html))
|
|
505
|
+
# Issue #182: also_update / also_replace collapsed into #also. Guided errors
|
|
506
|
+
# naming the rewrite (the html: kwarg lied — it accepted components too).
|
|
507
|
+
def also_update(target, **)
|
|
508
|
+
raise ArgumentError,
|
|
509
|
+
"also_update was removed in issue #182 — use also(#{target.inspect} => content)"
|
|
423
510
|
end
|
|
424
511
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
# `morph: true` morphs the companion in place (issue #28) — use it when the
|
|
429
|
-
# companion also holds focusable inputs that must survive the re-render.
|
|
430
|
-
def also_replace(component, morph: false)
|
|
431
|
-
stream(morph ? component.to_stream_morph : component.to_stream_replace)
|
|
512
|
+
def also_replace(*, **)
|
|
513
|
+
raise ArgumentError,
|
|
514
|
+
"also_replace was removed in issue #182 — use also(component, morph: …)"
|
|
432
515
|
end
|
|
433
516
|
|
|
434
517
|
def redirect? = !@redirect_url.nil?
|
|
@@ -448,6 +531,14 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
|
|
|
448
531
|
def default_js_target
|
|
449
532
|
(@subject_component || @token_component)&.id
|
|
450
533
|
end
|
|
534
|
+
|
|
535
|
+
# Build one update stream per { target_id => content } pair (issue #182). An
|
|
536
|
+
# empty call is a dead reply.also — fail loudly rather than return unchanged.
|
|
537
|
+
def also_targets(targets)
|
|
538
|
+
raise ArgumentError, "reply.also needs target => content pairs (or a component) — got nothing" if targets.empty?
|
|
539
|
+
|
|
540
|
+
stream(*targets.map { |target, content| self.class.update_stream(target, content) })
|
|
541
|
+
end
|
|
451
542
|
end
|
|
452
543
|
end
|
|
453
544
|
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# The ONE conditions language behind reactive_show / reactive_show_targets
|
|
6
|
+
# (issue #180 Phase A). It compiles Ruby-native values into a DNF wire shape
|
|
7
|
+
# and evaluates that shape in Ruby with semantics identical to the client —
|
|
8
|
+
# so first-paint `hidden:` (server) and live toggling (client) can never
|
|
9
|
+
# drift (the shared spec/fixtures/show_predicate_vectors.json proves it).
|
|
10
|
+
#
|
|
11
|
+
# THE VALUE LANGUAGE (used by every if:/if_any:/unless: term):
|
|
12
|
+
# String/Symbol/Integer/Float -> equals (stringified)
|
|
13
|
+
# true / false -> equals "true"/"false" (checkbox state)
|
|
14
|
+
# nil -> equals "" (blank)
|
|
15
|
+
# Array -> in (each stringified; empty raises)
|
|
16
|
+
# (10..) -> gte 10
|
|
17
|
+
# (..10) -> lte 10
|
|
18
|
+
# (...10) -> lt 10
|
|
19
|
+
# (10..20) -> gte 10 AND lte 20 (two terms, one group)
|
|
20
|
+
# Under unless: each term is NEGATED (De Morgan):
|
|
21
|
+
# scalar -> not; Array -> ∉ (AND of nots); Range -> the complement
|
|
22
|
+
# predicate (¬gte→lt, ¬lte→gt, ¬lt→gte), and a BOUNDED range complements
|
|
23
|
+
# to a disjunction (x<a ∨ x>b) that splits the group.
|
|
24
|
+
#
|
|
25
|
+
# THE WIRE (DNF): an ARRAY OF GROUPS. Terms AND within a group; groups OR.
|
|
26
|
+
# [[{ "field"=>"a", "equals"=>"1" }], [{ "field"=>"b", "gte"=>10 }]]
|
|
27
|
+
# A single if: compiles to one group; if_any: to one group per hash; unless:
|
|
28
|
+
# distributes its negated terms into every group (multiplying groups when a
|
|
29
|
+
# bounded-range complement splits). There is NO expression surface: every
|
|
30
|
+
# term is a declared literal predicate — the pre-#180 default-deny posture.
|
|
31
|
+
module ShowConditions
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# Compile if:/if_any:/unless: into DNF groups. Loud validation at render
|
|
35
|
+
# (the pre-#180 posture) — empty conditions, empty Array/group values, a
|
|
36
|
+
# non-Numeric Range endpoint, and if:+if_any: together all raise here so a
|
|
37
|
+
# dead binding fails at render, never silently in the browser.
|
|
38
|
+
def normalize(**kwargs)
|
|
39
|
+
positive = kwargs.slice(:if, :if_any)
|
|
40
|
+
negative = kwargs[:unless]
|
|
41
|
+
|
|
42
|
+
if positive.key?(:if) && positive.key?(:if_any)
|
|
43
|
+
raise ArgumentError,
|
|
44
|
+
"reactive_show: exactly one of if:/if_any: (unless: composes with either), " \
|
|
45
|
+
"got both"
|
|
46
|
+
end
|
|
47
|
+
raise ArgumentError, "reactive_show needs if:, if_any:, or unless:" unless positive.any? || negative
|
|
48
|
+
|
|
49
|
+
groups = base_groups(positive)
|
|
50
|
+
groups = apply_unless(groups, negative) if negative
|
|
51
|
+
groups
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Evaluate DNF groups against a { field => value(String) } map — true when
|
|
55
|
+
# ANY group's EVERY term matches. Mirrors the client fold exactly; a
|
|
56
|
+
# referenced field absent from `values` reads as "" (fail-closed, same as
|
|
57
|
+
# a blank field).
|
|
58
|
+
# rubocop:disable Style/ItBlockParameter -- nested blocks: `it` would shadow ambiguously
|
|
59
|
+
def match?(groups, values)
|
|
60
|
+
groups.any? { |group| group.all? { |term| term_matches?(term, values) } }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Every field named across all groups/terms — drives reactive_values
|
|
64
|
+
# coverage (server first paint only fires when every field is provided).
|
|
65
|
+
def fields(groups)
|
|
66
|
+
groups.flat_map { |group| group.map { |term| term.fetch("field") } }.uniq
|
|
67
|
+
end
|
|
68
|
+
# rubocop:enable Style/ItBlockParameter
|
|
69
|
+
|
|
70
|
+
# --- normalization internals -------------------------------------------
|
|
71
|
+
|
|
72
|
+
# The positive base: if: -> one group; if_any: -> a group per hash; neither
|
|
73
|
+
# (unless: only) -> a single empty "all true" group that unless: negates
|
|
74
|
+
# into.
|
|
75
|
+
def base_groups(positive)
|
|
76
|
+
if positive.key?(:if)
|
|
77
|
+
[group_terms(:if, positive.fetch(:if))]
|
|
78
|
+
elsif positive.key?(:if_any)
|
|
79
|
+
any = positive.fetch(:if_any)
|
|
80
|
+
unless any.is_a?(Array) && any.any?
|
|
81
|
+
raise ArgumentError, "reactive_show if_any: needs at least one group (an Array of hashes)"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
any.map { group_terms(:if_any, it) }
|
|
85
|
+
else
|
|
86
|
+
[[]] # unless: only — one empty group; unless distributes its nots in
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# One hash -> its ANDed positive terms.
|
|
91
|
+
def group_terms(context, hash)
|
|
92
|
+
unless hash.is_a?(Hash) && hash.any?
|
|
93
|
+
label = context == :if_any ? "each if_any: group needs at least one field" : "if: needs at least one field"
|
|
94
|
+
raise ArgumentError, "reactive_show: #{label} (a Hash of field => value), got #{hash.inspect}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
hash.flat_map { |field, value| positive_terms(field, value) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Distribute the negated unless: over every base group, obeying De Morgan.
|
|
101
|
+
# `unless:` negates the AND of its whole hash — ¬(a ∧ b) = ¬a ∨ ¬b — so the
|
|
102
|
+
# negation is a DISJUNCTION: the UNION of each field's alternative
|
|
103
|
+
# term-lists, never their cartesian product. Each field contributes 1
|
|
104
|
+
# alternative (a scalar `not`, or an Array's ∉ = AND-of-nots, or an endless
|
|
105
|
+
# range complement) or 2 (a bounded range, itself a disjunction); those
|
|
106
|
+
# alternatives OR across fields. Distributing that ORed set over the base
|
|
107
|
+
# groups multiplies them: |base| × |alternatives| groups, each base group
|
|
108
|
+
# ANDed with exactly one alternative.
|
|
109
|
+
def apply_unless(groups, negative)
|
|
110
|
+
unless negative.is_a?(Hash) && negative.any?
|
|
111
|
+
raise ArgumentError, "reactive_show unless: needs a Hash of field => value, got #{negative.inspect}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
alternatives = negative.flat_map { |field, value| negative_alternatives(field, value) }
|
|
115
|
+
# rubocop:disable Style/ItBlockParameter -- nested block: `it` would shadow `group`
|
|
116
|
+
groups.flat_map do |group|
|
|
117
|
+
alternatives.map { |extra| group + extra }
|
|
118
|
+
end
|
|
119
|
+
# rubocop:enable Style/ItBlockParameter
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# --- the value language (positive) -------------------------------------
|
|
123
|
+
|
|
124
|
+
# A field => value under if:/if_any: -> a list of ANDed terms (one, or two
|
|
125
|
+
# for a bounded range).
|
|
126
|
+
def positive_terms(field, value)
|
|
127
|
+
name = field.to_s
|
|
128
|
+
case value
|
|
129
|
+
when Range then range_terms(name, value)
|
|
130
|
+
when Array then [membership_term(name, value)]
|
|
131
|
+
else [{ "field" => name, "equals" => equals_literal(value) }]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def equals_literal(value)
|
|
136
|
+
return "" if value.nil?
|
|
137
|
+
|
|
138
|
+
value.to_s
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def membership_term(name, array)
|
|
142
|
+
list = array.map(&:to_s)
|
|
143
|
+
raise ArgumentError, "reactive_show: #{name.inspect} Array needs at least one value" if list.empty?
|
|
144
|
+
|
|
145
|
+
{ "field" => name, "in" => list }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# A Range -> gte / lte / lt terms. Endless -> one term; bounded -> two
|
|
149
|
+
# (gte + lte/lt) in the SAME group. Endpoints must be numbers (a literal
|
|
150
|
+
# numeric threshold, never an expression).
|
|
151
|
+
def range_terms(name, range)
|
|
152
|
+
first = range.begin
|
|
153
|
+
last = range.end
|
|
154
|
+
validate_range_endpoints!(name, first, last)
|
|
155
|
+
|
|
156
|
+
terms = []
|
|
157
|
+
terms << { "field" => name, "gte" => first } unless first.nil?
|
|
158
|
+
if last
|
|
159
|
+
terms << { "field" => name, (range.exclude_end? ? "lt" : "lte") => last }
|
|
160
|
+
end
|
|
161
|
+
terms
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def validate_range_endpoints!(name, first, last)
|
|
165
|
+
[first, last].compact.each do
|
|
166
|
+
next if it.is_a?(Numeric)
|
|
167
|
+
|
|
168
|
+
raise ArgumentError,
|
|
169
|
+
"reactive_show: #{name.inspect} Range endpoints must be numbers " \
|
|
170
|
+
"(an ordered threshold), got #{it.inspect}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# --- the value language (negated, under unless:) -----------------------
|
|
175
|
+
|
|
176
|
+
# A field => value under unless: -> a LIST of alternative term-lists.
|
|
177
|
+
# Scalar/Array negate to a single conjunctive alternative (an Array is
|
|
178
|
+
# ¬(x ∈ set) = AND of nots); a bounded Range complements to TWO
|
|
179
|
+
# alternatives (the disjunction x<a ∨ x>b). An empty Array raises, exactly
|
|
180
|
+
# like the positive membership path (a dead binding must fail at render).
|
|
181
|
+
def negative_alternatives(field, value)
|
|
182
|
+
name = field.to_s
|
|
183
|
+
case value
|
|
184
|
+
when Range then range_complement(name, value)
|
|
185
|
+
when Array
|
|
186
|
+
list = value.map(&:to_s)
|
|
187
|
+
raise ArgumentError, "reactive_show: #{name.inspect} Array needs at least one value" if list.empty?
|
|
188
|
+
|
|
189
|
+
[list.map { { "field" => name, "not" => it } }]
|
|
190
|
+
else [[{ "field" => name, "not" => equals_literal(value) }]]
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The complement of a range predicate. Endless ranges complement to a
|
|
195
|
+
# single opposite threshold (¬gte→lt, ¬lte→gt, ¬lt→gte) — itself a positive
|
|
196
|
+
# numeric predicate, so a blank/NaN field stays FALSE (fail-closed: the
|
|
197
|
+
# complement never flips a blank to visible). A bounded range complements
|
|
198
|
+
# to two alternatives.
|
|
199
|
+
def range_complement(name, range)
|
|
200
|
+
first = range.begin
|
|
201
|
+
last = range.end
|
|
202
|
+
validate_range_endpoints!(name, first, last)
|
|
203
|
+
|
|
204
|
+
low = first.nil? ? nil : [{ "field" => name, "lt" => first }]
|
|
205
|
+
high =
|
|
206
|
+
if last.nil?
|
|
207
|
+
nil
|
|
208
|
+
elsif range.exclude_end?
|
|
209
|
+
[{ "field" => name, "gte" => last }]
|
|
210
|
+
else
|
|
211
|
+
[{ "field" => name, "gt" => last }]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
[low, high].compact
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# --- evaluation (mirrors the client) -----------------------------------
|
|
218
|
+
|
|
219
|
+
def term_matches?(term, values)
|
|
220
|
+
field = term.fetch("field")
|
|
221
|
+
value = values.fetch(field, "")
|
|
222
|
+
|
|
223
|
+
if term.key?("equals") then value == term["equals"]
|
|
224
|
+
elsif term.key?("not") then value != term["not"]
|
|
225
|
+
elsif term.key?("in") then term["in"].include?(value)
|
|
226
|
+
else numeric_term_matches?(term, value)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# gte/gt/lte/lt — coerce the field value to a number; a blank/non-numeric
|
|
231
|
+
# value is fail-closed (never matches). Mirrors the client's
|
|
232
|
+
# numericPredicateMatches (blank/whitespace -> NaN -> false).
|
|
233
|
+
def numeric_term_matches?(term, value)
|
|
234
|
+
trimmed = value.to_s.strip
|
|
235
|
+
return false if trimmed.empty?
|
|
236
|
+
|
|
237
|
+
number = Float(trimmed, exception: false)
|
|
238
|
+
return false if number.nil?
|
|
239
|
+
|
|
240
|
+
if term.key?("gte") then number >= term["gte"]
|
|
241
|
+
elsif term.key?("gt") then number > term["gt"]
|
|
242
|
+
elsif term.key?("lte") then number <= term["lte"]
|
|
243
|
+
elsif term.key?("lt") then number < term["lt"]
|
|
244
|
+
else false
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|