phlex-reactive 0.15.2 → 0.15.4
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 +269 -2
- data/README.md +92 -13
- data/app/javascript/phlex/reactive/core.js +1 -0
- data/app/javascript/phlex/reactive/core.min.js +6 -6
- data/app/javascript/phlex/reactive/core.min.js.map +4 -4
- data/app/javascript/phlex/reactive/features/bindings.js +153 -21
- data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/countdown.js +140 -0
- data/app/javascript/phlex/reactive/features/countdown.min.js +4 -0
- data/app/javascript/phlex/reactive/features/countdown.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/form.js +54 -22
- data/app/javascript/phlex/reactive/features/form.min.js +2 -2
- data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/persist.js +41 -8
- data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
- data/app/javascript/phlex/reactive/features/persist.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +2 -0
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +9 -8
- data/app/javascript/phlex/reactive/runtime.js +313 -32
- data/lib/phlex/reactive/component/countdown_binding.rb +80 -0
- data/lib/phlex/reactive/component/field_references.rb +130 -0
- data/lib/phlex/reactive/component/helpers.rb +81 -19
- data/lib/phlex/reactive/component/identity.rb +34 -1
- data/lib/phlex/reactive/component/image_fallback_binding.rb +67 -0
- data/lib/phlex/reactive/component/media_binding.rb +30 -4
- data/lib/phlex/reactive/component/media_pattern.rb +79 -0
- data/lib/phlex/reactive/component/selection.rb +24 -2
- data/lib/phlex/reactive/component/show_options.rb +91 -0
- data/lib/phlex/reactive/doctor.rb +20 -8
- data/lib/phlex/reactive/engine.rb +2 -2
- data/lib/phlex/reactive/js.rb +58 -5
- data/lib/phlex/reactive/show_conditions/compare_terms.rb +142 -0
- data/lib/phlex/reactive/show_conditions.rb +39 -6
- data/lib/phlex/reactive/streamable.rb +6 -3
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +10 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# The reactive_show keywords beyond the condition itself.
|
|
7
|
+
#
|
|
8
|
+
# after: (issue #396) — keep the section hidden until the named field has
|
|
9
|
+
# fired `blur` once since connect. `after: :blur` names the condition's
|
|
10
|
+
# one field; a condition over several fields names it: `after: :code`.
|
|
11
|
+
# The client keeps the touched flag on the root's controller, so a morph
|
|
12
|
+
# keeps it and a full navigation (a new controller) drops it. The server
|
|
13
|
+
# cannot have seen a blur, so first paint is hidden.
|
|
14
|
+
#
|
|
15
|
+
# clear:, focus:, scroll: (issue #394) — work the client does when the
|
|
16
|
+
# section CHANGES visibility, never on first paint. Going hidden, each
|
|
17
|
+
# clear: field (owned, scope-aware) is emptied with js.set_value's write
|
|
18
|
+
# (a checkbox or radio is unchecked; a file input is rejected at render) and a bubbling
|
|
19
|
+
# `input` is dispatched per field that changed. Going visible, the first
|
|
20
|
+
# element inside the root matching focus: is focused, and scroll: true
|
|
21
|
+
# scrolls the section into view. clear: names are checked at render: an
|
|
22
|
+
# unknown one, or a file input, raises.
|
|
23
|
+
module ShowOptions
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# The wire data for a reactive_show's options, merged into its data:.
|
|
27
|
+
def show_option_data(groups, after:, clear:, focus:, scroll:)
|
|
28
|
+
data = {}
|
|
29
|
+
data[:reactive_show_after] = show_after_field!(groups, after) unless after.nil?
|
|
30
|
+
data[:reactive_show_clear] = show_clear_fields!(clear).to_json unless clear.nil?
|
|
31
|
+
data[:reactive_show_focus] = show_focus!(focus) unless focus.nil?
|
|
32
|
+
data[:reactive_show_scroll] = "true" if show_scroll!(scroll)
|
|
33
|
+
data
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def show_clear_fields!(clear)
|
|
37
|
+
names = Array(clear).map do
|
|
38
|
+
name = it.to_s if it.is_a?(Symbol) || it.is_a?(String)
|
|
39
|
+
next name unless name.nil? || name.strip.empty?
|
|
40
|
+
|
|
41
|
+
raise ArgumentError,
|
|
42
|
+
"reactive_show clear: takes a field name or an Array of them, got #{clear.inspect}"
|
|
43
|
+
end
|
|
44
|
+
raise ArgumentError, "reactive_show clear: needs at least one field name, got #{clear.inspect}" if names.empty?
|
|
45
|
+
|
|
46
|
+
reference_fields!(:reactive_show, :clear, names.uniq, no_file: true)
|
|
47
|
+
names.uniq
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def show_focus!(focus)
|
|
51
|
+
return focus if focus.is_a?(String) && !focus.strip.empty?
|
|
52
|
+
|
|
53
|
+
raise ArgumentError,
|
|
54
|
+
"reactive_show focus: takes a non-empty CSS selector inside the root (e.g. \"#notes\"), got #{focus.inspect}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def show_scroll!(scroll)
|
|
58
|
+
return scroll if [nil, true, false].include?(scroll)
|
|
59
|
+
|
|
60
|
+
raise ArgumentError, "reactive_show scroll: takes true or false, got #{scroll.inspect}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def show_after_field!(groups, after)
|
|
64
|
+
fields = Phlex::Reactive::ShowConditions.fields(groups)
|
|
65
|
+
unless after.is_a?(Symbol) || after.is_a?(String)
|
|
66
|
+
raise ArgumentError,
|
|
67
|
+
"reactive_show after: takes :blur or the name of the condition's field to wait for, got #{after.inspect}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
name = after == :blur ? show_after_single!(fields) : after.to_s
|
|
71
|
+
unless fields.include?(name)
|
|
72
|
+
raise ArgumentError,
|
|
73
|
+
"reactive_show after: #{after.inspect} is not a field of this condition " \
|
|
74
|
+
"(it names #{fields.map(&:to_sym).inspect})"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
reference_fields!(:reactive_show, :after, [name])
|
|
78
|
+
name
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def show_after_single!(fields)
|
|
82
|
+
return fields.first if fields.size == 1
|
|
83
|
+
|
|
84
|
+
raise ArgumentError,
|
|
85
|
+
"reactive_show after: :blur needs a condition over one field — this one names " \
|
|
86
|
+
"#{fields.map(&:to_sym).inspect}; pass the field to wait for (after: #{fields.first.to_sym.inspect})"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -248,7 +248,15 @@ module Phlex
|
|
|
248
248
|
# authorization / mark_authorized! call. It is :unknown, NEVER a hard fail:
|
|
249
249
|
# a helper may authorize indirectly, so this is a hint, not a verdict.
|
|
250
250
|
def authorization_check(components)
|
|
251
|
-
|
|
251
|
+
infos = nil
|
|
252
|
+
suspects = components.flat_map do
|
|
253
|
+
next [] if component_defines_authorization_method?(it)
|
|
254
|
+
|
|
255
|
+
# Read once, and only when needed: Inspector.components Prism-scans
|
|
256
|
+
# every component's actions.
|
|
257
|
+
infos ||= inspector_infos
|
|
258
|
+
unauthorized_action_labels(it, infos)
|
|
259
|
+
end
|
|
252
260
|
return Check.new(:ok, "every mutating action appears to authorize", name: :authorization) if suspects.empty?
|
|
253
261
|
|
|
254
262
|
Check.new(:unknown, "actions with no detected authorization call: #{suspects.join(", ")}",
|
|
@@ -394,13 +402,11 @@ module Phlex
|
|
|
394
402
|
end
|
|
395
403
|
|
|
396
404
|
# "Klass#action" for every declared action on `klass` that (advisory)
|
|
397
|
-
# appears unauthorized
|
|
398
|
-
# ancestry
|
|
399
|
-
# Inspector) finds no authorization / mark_authorized! call in its body.
|
|
400
|
-
def unauthorized_action_labels(klass)
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
info = Phlex::Reactive::Inspector.components.find { it.klass == klass }
|
|
405
|
+
# appears unauthorized (the caller has already ruled out an authorization
|
|
406
|
+
# method in its ancestry): the action isn't skipped, and the Prism scan (via
|
|
407
|
+
# the shared Inspector) finds no authorization / mark_authorized! call in its body.
|
|
408
|
+
def unauthorized_action_labels(klass, infos)
|
|
409
|
+
info = infos[klass]
|
|
404
410
|
return [] unless info
|
|
405
411
|
|
|
406
412
|
info.actions
|
|
@@ -417,6 +423,12 @@ module Phlex
|
|
|
417
423
|
# Phlex::Reactive.authorization_methods and NOT the Inspector's set, which
|
|
418
424
|
# folds in mark_authorized! — every component inherits that instance helper,
|
|
419
425
|
# so including it would silence the check for everything.
|
|
426
|
+
def inspector_infos
|
|
427
|
+
Phlex::Reactive::Inspector.components.to_h { [it.klass, it] }
|
|
428
|
+
rescue StandardError
|
|
429
|
+
{}
|
|
430
|
+
end
|
|
431
|
+
|
|
420
432
|
def component_defines_authorization_method?(klass)
|
|
421
433
|
Phlex::Reactive.authorization_methods.any? do
|
|
422
434
|
klass.method_defined?(it) || klass.private_method_defined?(it)
|
|
@@ -20,10 +20,10 @@ module Phlex
|
|
|
20
20
|
# build script (scripts/build_client.js), the core's own table and the
|
|
21
21
|
# default entry name the same features —
|
|
22
22
|
# spec/phlex/engine_client_pin_spec.rb fails when they disagree.
|
|
23
|
-
CLIENT_FEATURES = %w[persist defer form bindings compute sortable presence effects hints devtools].freeze
|
|
23
|
+
CLIENT_FEATURES = %w[persist defer form bindings compute sortable presence countdown effects hints devtools].freeze
|
|
24
24
|
# (Gzipped, bun zlib level 9, KB = 1,024 B: the default file 30.9 KB; the core 14.3 KB;
|
|
25
25
|
# persist 3.1, defer 2.9, form 1.1, bindings 6.5, compute 2.0, sortable
|
|
26
|
-
# 3.1, presence 1.3, effects 1.7, hints 0.9, devtools 1.5 KB. spec/javascript/bundle_budget.test.js
|
|
26
|
+
# 3.1, presence 1.3, countdown 0.9, effects 1.7, hints 0.9, devtools 1.5 KB. spec/javascript/bundle_budget.test.js
|
|
27
27
|
# is the source of truth and holds a ratchet on each.)
|
|
28
28
|
|
|
29
29
|
# Mount POST /reactive/actions -> Phlex::Reactive::ActionsController#create
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -193,6 +193,9 @@ module Phlex
|
|
|
193
193
|
# focus(to) — focus the FIRST match of the selector.
|
|
194
194
|
# focus_first(to) — focus the first FOCUSABLE DESCENDANT of the match
|
|
195
195
|
# (e.g. focus the first menuitem inside an opened menu).
|
|
196
|
+
# blur(to) — blur the focused element when it IS the match or sits
|
|
197
|
+
# inside it (issue #399): Escape ends a menu's
|
|
198
|
+
# :focus-within. Focus elsewhere is left alone.
|
|
196
199
|
|
|
197
200
|
def focus(to, global: false)
|
|
198
201
|
append("focus", target_args(to, global:))
|
|
@@ -202,6 +205,10 @@ module Phlex
|
|
|
202
205
|
append("focus_first", target_args(to, global:))
|
|
203
206
|
end
|
|
204
207
|
|
|
208
|
+
def blur(to, global: false)
|
|
209
|
+
append("blur", target_args(to, global:))
|
|
210
|
+
end
|
|
211
|
+
|
|
205
212
|
# --- Modal dialogs (issue #365) ---
|
|
206
213
|
#
|
|
207
214
|
# show_modal(to) — HTMLDialogElement#showModal(): the top layer, a
|
|
@@ -297,8 +304,33 @@ module Phlex
|
|
|
297
304
|
# that read every subscriber's file input would be hostile.
|
|
298
305
|
# input(type: "file", id: "photo-file", accept: "image/*",
|
|
299
306
|
# **on_client(:change, js.preview_file("#photo-file", into: "#photo", name: "#photo-name")))
|
|
300
|
-
|
|
301
|
-
|
|
307
|
+
#
|
|
308
|
+
# Upload validation (issue #382): `valid: true` treats an input whose
|
|
309
|
+
# validity is false (setCustomValidity, required) as empty, so a rejected
|
|
310
|
+
# file never gets a preview. An app that clears the input without a
|
|
311
|
+
# change event dispatches `reactive:file-changed` on it; bind the same op
|
|
312
|
+
# to that event too and the preview clears:
|
|
313
|
+
# op = js.preview_file("#photo-file", into: "#photo", valid: true)
|
|
314
|
+
# input(type: "file", id: "photo-file",
|
|
315
|
+
# **mix(on_client(:change, op), on_client("reactive:file-changed", op)))
|
|
316
|
+
#
|
|
317
|
+
# The box and the refusals (issue #391): `wrap:` is a CSS selector the
|
|
318
|
+
# client shows when it paints an image and hides when the preview clears
|
|
319
|
+
# (it shows again only a wrapper it hid itself, never one the author
|
|
320
|
+
# hid, and never writes its text). `max_bytes:` (a positive Integer)
|
|
321
|
+
# refuses a larger file before any object URL exists. `accept: true`
|
|
322
|
+
# reads the input's own accept attribute (extensions, MIME types,
|
|
323
|
+
# type/*); a String is that list for this op; omitted keeps the image/*
|
|
324
|
+
# rule. Only an image is painted: a listed non-image (a PDF) clears the
|
|
325
|
+
# preview and hides `wrap:`, unrefused. A refused file clears the img,
|
|
326
|
+
# hides `wrap:` and dispatches
|
|
327
|
+
# `reactive:file-rejected` on the input (detail.reason "type" | "size").
|
|
328
|
+
# The op never clears input.files nor sets a custom validity message.
|
|
329
|
+
# input(type: "file", id: "photo-file", accept: "image/png,image/jpeg",
|
|
330
|
+
# **on_client(:change, js.preview_file("#photo-file", into: "#photo",
|
|
331
|
+
# wrap: "#photo-frame", max_bytes: 2_000_000, accept: true)))
|
|
332
|
+
|
|
333
|
+
def preview_file(to, into:, name: nil, global: false, valid: false, wrap: nil, max_bytes: nil, accept: nil)
|
|
302
334
|
if [:root, ROOT_SENTINEL].include?(to)
|
|
303
335
|
raise ArgumentError,
|
|
304
336
|
"#{self.class}: preview_file reads a file input, not the component root — " \
|
|
@@ -308,6 +340,10 @@ module Phlex
|
|
|
308
340
|
args = { "to" => normalize_target(to), "into" => preview_selector(:into, into) }
|
|
309
341
|
args["name"] = preview_selector(:name, name) unless name.nil?
|
|
310
342
|
args["global"] = true if global
|
|
343
|
+
args["valid"] = true if valid
|
|
344
|
+
args["wrap"] = preview_selector(:wrap, wrap) unless wrap.nil?
|
|
345
|
+
args["max_bytes"] = preview_max_bytes(max_bytes) unless max_bytes.nil?
|
|
346
|
+
args["accept"] = preview_accept(accept) unless accept.nil? || accept == false
|
|
311
347
|
append("preview_file", args.freeze)
|
|
312
348
|
end
|
|
313
349
|
|
|
@@ -331,9 +367,10 @@ module Phlex
|
|
|
331
367
|
# onto the `to` <img>. Both are ELEMENTS (:root or a CSS selector,
|
|
332
368
|
# resolved with the op's own scoping, global: included): the URL is read
|
|
333
369
|
# off the live source element in the browser and is never an argument,
|
|
334
|
-
# so the set_attr refusal of src (issue #96) stays. A missing
|
|
335
|
-
# non-<img> source
|
|
336
|
-
#
|
|
370
|
+
# so the set_attr refusal of src (issue #96) stays. A missing or
|
|
371
|
+
# non-<img> source clears the target's src, srcset and alt; an <img>
|
|
372
|
+
# source without src removes the target's src and srcset and copies its
|
|
373
|
+
# alt (issue #381); a target that is not an <img> is skipped. The same URL twice
|
|
337
374
|
# is not written again (no reload). Broadcast-safe: it only reads
|
|
338
375
|
# elements already in the subscriber's document.
|
|
339
376
|
# button(**on_client(:click, js.copy_media("#logo", from: "#logo-007"))) { "North" }
|
|
@@ -516,6 +553,22 @@ module Phlex
|
|
|
516
553
|
"#{self.class}: preview_file #{key}: must be a CSS selector string, got #{selector.inspect}"
|
|
517
554
|
end
|
|
518
555
|
|
|
556
|
+
def preview_max_bytes(bytes)
|
|
557
|
+
return bytes if bytes.is_a?(Integer) && bytes.positive?
|
|
558
|
+
|
|
559
|
+
raise ArgumentError,
|
|
560
|
+
"#{self.class}: preview_file max_bytes: must be a positive Integer, got #{bytes.inspect}"
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# true reads the input's accept attribute; a String is the list itself.
|
|
564
|
+
def preview_accept(accept)
|
|
565
|
+
return accept if accept == true || (accept.is_a?(String) && !accept.strip.empty?)
|
|
566
|
+
|
|
567
|
+
raise ArgumentError,
|
|
568
|
+
"#{self.class}: preview_file accept: must be true or an accept list " \
|
|
569
|
+
"(e.g. \"image/png,.jpg\"), got #{accept.inspect}"
|
|
570
|
+
end
|
|
571
|
+
|
|
519
572
|
# The [on, off] pair of a two-value toggle_attr, stringified. Loud on a
|
|
520
573
|
# half-specified pair or one that could never change anything.
|
|
521
574
|
def toggle_values(name, values)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module ShowConditions
|
|
6
|
+
# The Hash-valued terms that are not a count: `same:` (issue #395) — a
|
|
7
|
+
# field equal to ANOTHER owned field — and `digits:` (issue #396) — a
|
|
8
|
+
# closed format: ASCII digits only, an exact count or an Integer Range,
|
|
9
|
+
# and an optional digit prefix (`starts_with:`). Compiled and evaluated
|
|
10
|
+
# here; the client mirrors both in features/bindings.js, and the shared
|
|
11
|
+
# fixture (spec/fixtures/show_predicate_vectors.json) holds them to
|
|
12
|
+
# agreement.
|
|
13
|
+
#
|
|
14
|
+
# A negated term (under unless:) keeps its shape and carries
|
|
15
|
+
# "negate" => true, the way unless: turns equals into not.
|
|
16
|
+
module CompareTerms
|
|
17
|
+
# The digit-count wire keys: the count suffixes length: uses.
|
|
18
|
+
DIGITS_KEYS = %w[digits_eq digits_gte digits_lt digits_lte].freeze
|
|
19
|
+
DIGIT_STRING = /\A[0-9]*\z/
|
|
20
|
+
PREFIX = /\A[0-9]+\z/
|
|
21
|
+
|
|
22
|
+
# { digits: 10, starts_with: "9" } -> ONE term (a Range's bounds sit in
|
|
23
|
+
# the same term, so unless: can negate the whole format at once).
|
|
24
|
+
def digits_term(name, hash, negate:)
|
|
25
|
+
options = option_keys(hash)
|
|
26
|
+
if (extra = options.keys - %i[digits starts_with]).any?
|
|
27
|
+
raise ArgumentError, "reactive_show: #{name.inspect} digits: takes only starts_with: (got #{extra.inspect})"
|
|
28
|
+
end
|
|
29
|
+
unless options.key?(:digits)
|
|
30
|
+
raise ArgumentError, "reactive_show: #{name.inspect} starts_with: needs digits: (e.g. digits: 10)"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
term = { "field" => name }.merge(digits_bounds(name, options[:digits]))
|
|
34
|
+
term["prefix"] = digits_prefix!(name, options[:starts_with]) if options.key?(:starts_with)
|
|
35
|
+
term["negate"] = true if negate
|
|
36
|
+
term
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# digits: — every character is 0-9, the count fits every bound, and the
|
|
40
|
+
# value starts with the prefix; negate flips it. A malformed literal
|
|
41
|
+
# (a hand-built term) is false either way, like the client's skip.
|
|
42
|
+
def digits_matches?(term, value)
|
|
43
|
+
keys = DIGITS_KEYS.select { term.key?(it) }
|
|
44
|
+
return false unless keys.all? { term[it].is_a?(Integer) } && (term["prefix"].nil? || term["prefix"].is_a?(String))
|
|
45
|
+
|
|
46
|
+
text = value.to_s
|
|
47
|
+
ok = text.match?(DIGIT_STRING) &&
|
|
48
|
+
keys.all? { count_matches?(it, term[it], text.length) } &&
|
|
49
|
+
(term["prefix"].nil? || text.start_with?(term["prefix"]))
|
|
50
|
+
ok != (term["negate"] == true)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Symbol/String keys as Symbols; any other key stays as it is, so the
|
|
54
|
+
# unknown-key check names it in an ArgumentError.
|
|
55
|
+
def option_keys(hash)
|
|
56
|
+
hash.transform_keys { it.is_a?(String) || it.is_a?(Symbol) ? it.to_sym : it }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# { same: :other, case: :fold } -> one term comparing this field's
|
|
60
|
+
# value with another owned field's, both read as strings. The other
|
|
61
|
+
# name is a bare field like any show field (the client applies
|
|
62
|
+
# reactive_scope).
|
|
63
|
+
def same_term(name, hash, negate:)
|
|
64
|
+
options = option_keys(hash)
|
|
65
|
+
if (extra = options.keys - %i[same case]).any?
|
|
66
|
+
raise ArgumentError, "reactive_show: #{name.inspect} same: takes only case: (got #{extra.inspect})"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
term = { "field" => name, "same" => same_field!(name, options[:same]) }
|
|
70
|
+
term["fold"] = true if same_case!(name, options[:case])
|
|
71
|
+
term["negate"] = true if negate
|
|
72
|
+
term
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# same: — this field's value against the other field's (an absent
|
|
76
|
+
# field is ""), folded when the term says so; negate flips it.
|
|
77
|
+
def same_matches?(term, value, values)
|
|
78
|
+
left = value.to_s
|
|
79
|
+
right = values.fetch(term["same"], "").to_s
|
|
80
|
+
if term["fold"] == true
|
|
81
|
+
left = ascii_fold(left)
|
|
82
|
+
right = ascii_fold(right)
|
|
83
|
+
end
|
|
84
|
+
(left == right) != (term["negate"] == true)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The ASCII-only fold both evaluators apply: A-Z to a-z and nothing
|
|
88
|
+
# else (never downcase, which folds letters the client would not).
|
|
89
|
+
def ascii_fold(value)
|
|
90
|
+
value.tr("A-Z", "a-z")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def digits_bounds(name, digits)
|
|
96
|
+
case digits
|
|
97
|
+
when Integer
|
|
98
|
+
return { "digits_eq" => digits } if digits >= 0
|
|
99
|
+
when Range
|
|
100
|
+
first = digits.begin
|
|
101
|
+
last = digits.end
|
|
102
|
+
if [first, last].compact.any? && [first, last].compact.all? { it.is_a?(Integer) && it >= 0 }
|
|
103
|
+
bounds = {}
|
|
104
|
+
bounds["digits_gte"] = first unless first.nil?
|
|
105
|
+
bounds[digits.exclude_end? ? "digits_lt" : "digits_lte"] = last unless last.nil?
|
|
106
|
+
return bounds
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
raise ArgumentError,
|
|
111
|
+
"reactive_show: #{name.inspect} digits: takes a non-negative Integer or an Integer Range " \
|
|
112
|
+
"(a digit count), got #{digits.inspect}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def digits_prefix!(name, prefix)
|
|
116
|
+
return prefix if prefix.is_a?(String) && prefix.match?(PREFIX)
|
|
117
|
+
|
|
118
|
+
raise ArgumentError,
|
|
119
|
+
"reactive_show: #{name.inspect} starts_with: takes a String of digits (0-9), got #{prefix.inspect}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def same_field!(name, other)
|
|
123
|
+
field = other.to_s if other.is_a?(Symbol) || other.is_a?(String)
|
|
124
|
+
return field unless field.nil? || field.strip.empty?
|
|
125
|
+
|
|
126
|
+
raise ArgumentError,
|
|
127
|
+
"reactive_show: #{name.inspect} same: needs the other field's name (e.g. same: :email), " \
|
|
128
|
+
"got #{other.inspect}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# case: is absent (an exact compare) or :fold. Anything else raises:
|
|
132
|
+
# there is no locale-aware fold to ask for.
|
|
133
|
+
def same_case!(name, value)
|
|
134
|
+
return false if value.nil?
|
|
135
|
+
return true if (value.is_a?(Symbol) || value.is_a?(String)) && value.to_s == "fold"
|
|
136
|
+
|
|
137
|
+
raise ArgumentError, "reactive_show: #{name.inspect} case: takes only :fold, got #{value.inspect}"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -23,6 +23,9 @@ module Phlex
|
|
|
23
23
|
# { checked: 1.. } / { checked: 0 } -> checked_* (issue #319 — how many
|
|
24
24
|
# owned boxes of a checkbox GROUP are
|
|
25
25
|
# ticked; same Integer/Range shapes)
|
|
26
|
+
# { same: :email } -> same "email" (issue #395 — equal to
|
|
27
|
+
# { same: :email, case: :fold } ANOTHER owned field; fold lowercases
|
|
28
|
+
# ASCII A-Z only, on both sides)
|
|
26
29
|
# Under unless: each term is NEGATED (De Morgan):
|
|
27
30
|
# scalar -> not; Array -> ∉ (AND of nots); Range -> the complement
|
|
28
31
|
# predicate (¬gte→lt, ¬lte→gt, ¬lt→gte), and a BOUNDED range complements
|
|
@@ -49,6 +52,8 @@ module Phlex
|
|
|
49
52
|
# A Hash value's one key -> its wire-key prefix.
|
|
50
53
|
COUNT_PREFIXES = { "length" => "len", "checked" => "checked" }.freeze
|
|
51
54
|
|
|
55
|
+
extend CompareTerms
|
|
56
|
+
|
|
52
57
|
module_function
|
|
53
58
|
|
|
54
59
|
# Compile if:/if_any:/unless: into DNF groups. Loud validation at render
|
|
@@ -83,7 +88,7 @@ module Phlex
|
|
|
83
88
|
# Every field named across all groups/terms — drives reactive_values
|
|
84
89
|
# coverage (server first paint only fires when every field is provided).
|
|
85
90
|
def fields(groups)
|
|
86
|
-
groups.flat_map { |group| group.
|
|
91
|
+
groups.flat_map { |group| group.flat_map { |term| [term.fetch("field"), term["same"]].compact } }.uniq
|
|
87
92
|
end
|
|
88
93
|
# rubocop:enable Style/ItBlockParameter
|
|
89
94
|
|
|
@@ -147,7 +152,7 @@ module Phlex
|
|
|
147
152
|
case value
|
|
148
153
|
when Range then range_terms(name, value)
|
|
149
154
|
when Array then [membership_term(name, value)]
|
|
150
|
-
when Hash then
|
|
155
|
+
when Hash then hash_terms(name, value)
|
|
151
156
|
else [{ "field" => name, "equals" => equals_literal(value) }]
|
|
152
157
|
end
|
|
153
158
|
end
|
|
@@ -191,6 +196,32 @@ module Phlex
|
|
|
191
196
|
end
|
|
192
197
|
end
|
|
193
198
|
|
|
199
|
+
# --- Hash values: same: (#395, in CompareTerms) or a count -------------
|
|
200
|
+
|
|
201
|
+
def hash_terms(name, hash)
|
|
202
|
+
case hash_kind(hash)
|
|
203
|
+
when :same then [same_term(name, hash, negate: false)]
|
|
204
|
+
when :digits then [digits_term(name, hash, negate: false)]
|
|
205
|
+
else count_terms(name, hash)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The complement under unless: — a list of alternative term-lists.
|
|
210
|
+
def hash_complement(name, hash)
|
|
211
|
+
case hash_kind(hash)
|
|
212
|
+
when :same then [[same_term(name, hash, negate: true)]]
|
|
213
|
+
when :digits then [[digits_term(name, hash, negate: true)]]
|
|
214
|
+
else count_complement(name, hash)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def hash_kind(hash)
|
|
219
|
+
keys = hash.keys.map(&:to_s)
|
|
220
|
+
if keys.include?("same") then :same
|
|
221
|
+
elsif keys.include?("digits") || keys.include?("starts_with") then :digits
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
194
225
|
# --- the count predicates: length: (#226) and checked: (#319) ---------
|
|
195
226
|
|
|
196
227
|
# A Hash value names a STRUCTURAL predicate over a COUNT — length: (the
|
|
@@ -262,7 +293,7 @@ module Phlex
|
|
|
262
293
|
kind = hash.keys.first.to_s if hash.size == 1
|
|
263
294
|
unless COUNT_PREFIXES.key?(kind)
|
|
264
295
|
raise ArgumentError,
|
|
265
|
-
"reactive_show: #{name.inspect} Hash value supports only length: or checked: " \
|
|
296
|
+
"reactive_show: #{name.inspect} Hash value supports only same:, digits:, or one of length: or checked: " \
|
|
266
297
|
"(got #{hash.keys.inspect})"
|
|
267
298
|
end
|
|
268
299
|
|
|
@@ -294,7 +325,7 @@ module Phlex
|
|
|
294
325
|
name = field.to_s
|
|
295
326
|
case value
|
|
296
327
|
when Range then range_complement(name, value)
|
|
297
|
-
when Hash then
|
|
328
|
+
when Hash then hash_complement(name, value)
|
|
298
329
|
when Array
|
|
299
330
|
list = value.map(&:to_s)
|
|
300
331
|
raise ArgumentError, "reactive_show: #{name.inspect} Array needs at least one value" if list.empty?
|
|
@@ -333,7 +364,9 @@ module Phlex
|
|
|
333
364
|
field = term.fetch("field")
|
|
334
365
|
value = values.fetch(field, "")
|
|
335
366
|
|
|
336
|
-
if term.key?("
|
|
367
|
+
if term.key?("same") then same_matches?(term, value, values)
|
|
368
|
+
elsif CompareTerms::DIGITS_KEYS.any? { term.key?(it) } then digits_matches?(term, value)
|
|
369
|
+
elsif term.key?("equals") then value == term["equals"]
|
|
337
370
|
elsif term.key?("not") then value != term["not"]
|
|
338
371
|
elsif term.key?("in") then term["in"].include?(value)
|
|
339
372
|
elsif (key = LENGTH_KEYS.find { term.key?(it) }) then count_matches?(key, term[key], value.to_s.length)
|
|
@@ -350,7 +383,7 @@ module Phlex
|
|
|
350
383
|
def count_matches?(key, literal, count)
|
|
351
384
|
return false unless literal.is_a?(Integer)
|
|
352
385
|
|
|
353
|
-
case key.
|
|
386
|
+
case key.split("_", 2).last
|
|
354
387
|
when "eq" then count == literal
|
|
355
388
|
when "gte" then count >= literal
|
|
356
389
|
when "gt" then count > literal
|
|
@@ -34,7 +34,9 @@ module Phlex
|
|
|
34
34
|
ThreadViewContext = Struct.new(:view_context, :builder, :renderer, :generation)
|
|
35
35
|
|
|
36
36
|
# Actor-only ops: a js broadcast rejects a broadcast that carries one.
|
|
37
|
-
# Focus ops steal focus in every subscriber's tab (issue #96);
|
|
37
|
+
# Focus ops steal focus in every subscriber's tab (issue #96); blur (issue
|
|
38
|
+
# #399) would drop focus in every subscriber whose focused element sits
|
|
39
|
+
# inside the target; submit
|
|
38
40
|
# (issue #226) would force-submit every subscriber's form; paste_into
|
|
39
41
|
# (issue #228) would read every subscriber's clipboard; check_group
|
|
40
42
|
# (issue #342) would tick or clear every subscriber's selection; presence
|
|
@@ -46,7 +48,7 @@ module Phlex
|
|
|
46
48
|
# belong to the actor's own reply (reply.js) or gesture (on_client / a
|
|
47
49
|
# reducer's $ops), never a broadcast. Names mirror Phlex::Reactive::JS's verbs.
|
|
48
50
|
BROADCAST_REFUSED_OPS = %w[
|
|
49
|
-
focus focus_first submit paste_into persist_state persist_clear check_group presence
|
|
51
|
+
focus focus_first blur submit paste_into persist_state persist_clear check_group presence
|
|
50
52
|
show_modal close_dialog
|
|
51
53
|
preview_file
|
|
52
54
|
set_value
|
|
@@ -205,7 +207,8 @@ module Phlex
|
|
|
205
207
|
unless refused.empty?
|
|
206
208
|
raise ArgumentError,
|
|
207
209
|
"broadcast_to(js:) refuses actor-only op(s) #{refused.join(", ")} — broadcasting focus " \
|
|
208
|
-
"steals it in every subscriber's tab; broadcasting
|
|
210
|
+
"steals it in every subscriber's tab; broadcasting blur drops focus in every subscriber " \
|
|
211
|
+
"whose focused element is inside the target; broadcasting submit force-submits every " \
|
|
209
212
|
"subscriber's form; broadcasting paste_into reads every subscriber's clipboard; " \
|
|
210
213
|
"broadcasting check_group ticks or clears every subscriber's selection; " \
|
|
211
214
|
"broadcasting presence writes every subscriber's presence state; " \
|
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.15.
|
|
4
|
+
version: 0.15.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -140,6 +140,9 @@ files:
|
|
|
140
140
|
- app/javascript/phlex/reactive/features/compute.js
|
|
141
141
|
- app/javascript/phlex/reactive/features/compute.min.js
|
|
142
142
|
- app/javascript/phlex/reactive/features/compute.min.js.map
|
|
143
|
+
- app/javascript/phlex/reactive/features/countdown.js
|
|
144
|
+
- app/javascript/phlex/reactive/features/countdown.min.js
|
|
145
|
+
- app/javascript/phlex/reactive/features/countdown.min.js.map
|
|
143
146
|
- app/javascript/phlex/reactive/features/defer.js
|
|
144
147
|
- app/javascript/phlex/reactive/features/defer.min.js
|
|
145
148
|
- app/javascript/phlex/reactive/features/defer.min.js.map
|
|
@@ -193,15 +196,20 @@ files:
|
|
|
193
196
|
- lib/phlex/reactive/client_bindings.rb
|
|
194
197
|
- lib/phlex/reactive/collections.rb
|
|
195
198
|
- lib/phlex/reactive/component.rb
|
|
199
|
+
- lib/phlex/reactive/component/countdown_binding.rb
|
|
196
200
|
- lib/phlex/reactive/component/dsl.rb
|
|
197
201
|
- lib/phlex/reactive/component/editable.rb
|
|
202
|
+
- lib/phlex/reactive/component/field_references.rb
|
|
198
203
|
- lib/phlex/reactive/component/helpers.rb
|
|
199
204
|
- lib/phlex/reactive/component/identity.rb
|
|
205
|
+
- lib/phlex/reactive/component/image_fallback_binding.rb
|
|
200
206
|
- lib/phlex/reactive/component/lazy.rb
|
|
201
207
|
- lib/phlex/reactive/component/media_binding.rb
|
|
208
|
+
- lib/phlex/reactive/component/media_pattern.rb
|
|
202
209
|
- lib/phlex/reactive/component/presence_declaration.rb
|
|
203
210
|
- lib/phlex/reactive/component/registry.rb
|
|
204
211
|
- lib/phlex/reactive/component/selection.rb
|
|
212
|
+
- lib/phlex/reactive/component/show_options.rb
|
|
205
213
|
- lib/phlex/reactive/component/sortable.rb
|
|
206
214
|
- lib/phlex/reactive/defer.rb
|
|
207
215
|
- lib/phlex/reactive/deferred_render_job.rb
|
|
@@ -239,6 +247,7 @@ files:
|
|
|
239
247
|
- lib/phlex/reactive/settles.rb
|
|
240
248
|
- lib/phlex/reactive/shortcut.rb
|
|
241
249
|
- lib/phlex/reactive/show_conditions.rb
|
|
250
|
+
- lib/phlex/reactive/show_conditions/compare_terms.rb
|
|
242
251
|
- lib/phlex/reactive/stream.rb
|
|
243
252
|
- lib/phlex/reactive/streamable.rb
|
|
244
253
|
- lib/phlex/reactive/test_helpers.rb
|