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.
@@ -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
- # Response.replace(self) # re-render in place (the default, explicit)
14
- # Response.replace(self).flash(:error, msg) # surface a validation error
15
- # Response.replace(self).also_update("heading", html: @record.name) # + a companion element
16
- # Response.remove(self) # drop the element (e.g. moderation queue)
17
- # Response.redirect(article_url(@article)) # slug changed -> Turbo.visit the new URL
18
- # Response.replace(self).stream(Totals.update(@order)) # multi-stream
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 .morph (issue #28).
26
- def replace(component, morph: false)
27
- new(streams: [morph ? component.to_stream_morph : component.to_stream_replace],
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 morph(component) = new(streams: [component.to_stream_morph], subject_component: component)
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 update(component, morph: false)
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 remove(component) = new(streams: [component.to_stream_remove], render_self: false)
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 redirect(url) = new(redirect_url: url, render_self: false)
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 with(*strings) = new(streams: strings.flatten)
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 collection_append(component, name, model)
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 collection_prepend(component, name, model)
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 collection_remove(component, name, model)
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.reactive_collection_def(name) ||
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
- streams = [definition.item.public_send(action, target: definition.container, model:)]
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
- # Response.streams(self, Totals.update(@invoice)) # update only the totals
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 streams(component, *strings)
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
- component_class = Phlex::Reactive.flash_component
198
- if component_class
199
- html = render_html(component_class.new(level:, content:))
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 #also_update to
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(level, content, target:, dismiss_after:))
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
- # Also re-render a COMPANION element alongside self a page heading, a
412
- # summary card, a badge that recomputes from the saved value (issue #25).
413
- # `target` is the sibling element's DOM id. `html` is either:
414
- # * a plain String — HTML-ESCAPED by Turbo, so a model value is safe:
415
- # Response.replace(self).also_update("page_heading", html: @record.name)
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
- # Like #also_update, but renders ANOTHER Streamable component and replaces
426
- # it by its own #id — for a companion that is itself a component.
427
- # Response.replace(self).also_replace(SummaryCard.new(order: @order))
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