funicular 0.1.0 → 0.2.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 +24 -0
- data/README.md +10 -2
- data/Rakefile +29 -0
- data/docs/architecture.md +113 -404
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/compiler.rb +23 -15
- data/lib/funicular/helpers/picoruby_helper.rb +65 -3
- data/lib/funicular/middleware.rb +34 -9
- data/lib/funicular/plugin.rb +147 -0
- data/lib/funicular/schema.rb +167 -0
- data/lib/funicular/ssr/runtime.rb +101 -0
- data/lib/funicular/ssr.rb +51 -0
- data/lib/funicular/testing/node_runner.mjs +293 -0
- data/lib/funicular/testing/node_runner.rb +190 -0
- data/lib/funicular/testing.rb +22 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +94 -75
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +3 -0
- data/lib/generators/funicular/chat/chat_generator.rb +104 -0
- data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
- data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
- data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
- data/lib/tasks/funicular.rake +87 -4
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/hydration_test.rb +87 -0
- data/minitest/plugin_test.rb +51 -0
- data/minitest/schema_test.rb +106 -0
- data/minitest/ssr_test.rb +94 -0
- data/minitest/validations_test.rb +183 -0
- data/mrbgem.rake +1 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +24 -9
- data/mrblib/component.rb +172 -33
- data/mrblib/debug.rb +3 -0
- data/mrblib/differ.rb +47 -37
- data/mrblib/file_upload.rb +9 -1
- data/mrblib/form_builder.rb +21 -5
- data/mrblib/funicular.rb +97 -8
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +123 -29
- data/mrblib/model.rb +50 -0
- data/mrblib/patcher.rb +74 -8
- data/mrblib/router.rb +40 -3
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/sig/cable.rbs +1 -0
- data/sig/component.rbs +13 -5
- data/sig/funicular.rbs +14 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +21 -6
- data/sig/model.rbs +6 -1
- data/sig/patcher.rbs +4 -1
- data/sig/router.rbs +3 -2
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +6 -6
- metadata +47 -12
- data/docs/README.md +0 -419
- data/docs/advanced-features.md +0 -632
- data/docs/components-and-state.md +0 -539
- data/docs/data-fetching.md +0 -528
- data/docs/forms.md +0 -446
- data/docs/rails-integration.md +0 -426
- data/docs/realtime.md +0 -543
- data/docs/routing-and-navigation.md +0 -427
- data/docs/styling.md +0 -285
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# Load the mrblib runtime before defining model subclasses below, since their
|
|
6
|
+
# class bodies reference Funicular::Model and call `validates` at load time.
|
|
7
|
+
Funicular::SSR::Runtime.load_framework!
|
|
8
|
+
|
|
9
|
+
# Exercises the client-side validation framework under CRuby (the same code
|
|
10
|
+
# runs in the browser under PicoRuby; see test/validations_test.rb).
|
|
11
|
+
class ValidationsTest < Minitest::Test
|
|
12
|
+
# A fresh, isolated model class with the given schema so validators declared
|
|
13
|
+
# in one test never leak into another.
|
|
14
|
+
def model_class(attributes = { "name" => false, "age" => false }, endpoints = {})
|
|
15
|
+
klass = Class.new(Funicular::Model)
|
|
16
|
+
attrs = {}
|
|
17
|
+
attributes.each { |name, readonly| attrs[name] = { "type" => "string", "readonly" => readonly } }
|
|
18
|
+
klass.load_schema("attributes" => attrs, "endpoints" => endpoints)
|
|
19
|
+
klass
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# --- individual validators -------------------------------------------
|
|
23
|
+
|
|
24
|
+
def test_presence
|
|
25
|
+
k = model_class
|
|
26
|
+
k.class_eval { validates :name, presence: true }
|
|
27
|
+
blank = k.new("name" => "")
|
|
28
|
+
assert_equal false, blank.valid?
|
|
29
|
+
assert_equal ["can't be blank"], blank.errors[:name]
|
|
30
|
+
assert_equal true, k.new("name" => "Alice").valid?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_length_maximum_and_minimum
|
|
34
|
+
k = model_class
|
|
35
|
+
k.class_eval { validates :name, length: { minimum: 2, maximum: 5 } }
|
|
36
|
+
|
|
37
|
+
short = k.new("name" => "a"); short.valid?
|
|
38
|
+
assert_equal ["is too short (minimum is 2 characters)"], short.errors[:name]
|
|
39
|
+
|
|
40
|
+
long = k.new("name" => "abcdef"); long.valid?
|
|
41
|
+
assert_equal ["is too long (maximum is 5 characters)"], long.errors[:name]
|
|
42
|
+
|
|
43
|
+
assert_equal true, k.new("name" => "abc").valid?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def test_format
|
|
47
|
+
k = model_class
|
|
48
|
+
k.class_eval { validates :name, format: { with: /^[a-z]+$/ } }
|
|
49
|
+
assert_equal true, k.new("name" => "abc").valid?
|
|
50
|
+
bad = k.new("name" => "ABC"); bad.valid?
|
|
51
|
+
assert_equal ["is invalid"], bad.errors[:name]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_numericality
|
|
55
|
+
k = model_class
|
|
56
|
+
k.class_eval { validates :age, numericality: { only_integer: true, greater_than: 0 } }
|
|
57
|
+
nan = k.new("age" => "abc"); nan.valid?
|
|
58
|
+
assert_equal ["is not a number"], nan.errors[:age]
|
|
59
|
+
assert_equal true, k.new("age" => "10").valid?
|
|
60
|
+
low = k.new("age" => "0"); low.valid?
|
|
61
|
+
assert_equal ["must be greater than 0"], low.errors[:age]
|
|
62
|
+
frac = k.new("age" => "1.5"); frac.valid?
|
|
63
|
+
assert_equal ["must be an integer"], frac.errors[:age]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def test_inclusion_and_exclusion
|
|
67
|
+
inc = model_class
|
|
68
|
+
inc.class_eval { validates :name, inclusion: { in: ["a", "b"] } }
|
|
69
|
+
assert_equal true, inc.new("name" => "a").valid?
|
|
70
|
+
assert_equal false, inc.new("name" => "z").valid?
|
|
71
|
+
|
|
72
|
+
exc = model_class
|
|
73
|
+
exc.class_eval { validates :name, exclusion: { in: ["admin"] } }
|
|
74
|
+
assert_equal false, exc.new("name" => "admin").valid?
|
|
75
|
+
assert_equal true, exc.new("name" => "alice").valid?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def test_allow_blank_skips_validation
|
|
79
|
+
k = model_class
|
|
80
|
+
k.class_eval { validates :name, length: { minimum: 3 }, allow_blank: true }
|
|
81
|
+
assert_equal true, k.new("name" => "").valid?
|
|
82
|
+
assert_equal false, k.new("name" => "ab").valid?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def test_valid_clears_previous_errors
|
|
86
|
+
k = model_class
|
|
87
|
+
k.class_eval { validates :name, presence: true }
|
|
88
|
+
m = k.new("name" => "")
|
|
89
|
+
m.valid?
|
|
90
|
+
m.instance_variable_set("@name", "now set")
|
|
91
|
+
assert_equal true, m.valid?
|
|
92
|
+
assert_equal [], m.errors[:name]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# --- schema-derived validators + merge -------------------------------
|
|
96
|
+
|
|
97
|
+
def test_load_schema_merges_and_dedupes
|
|
98
|
+
k = Class.new(Funicular::Model)
|
|
99
|
+
k.class_eval { validates :name, presence: true } # client-declared
|
|
100
|
+
k.load_schema(
|
|
101
|
+
"attributes" => {
|
|
102
|
+
"name" => { "type" => "string", "readonly" => false },
|
|
103
|
+
"age" => { "type" => "string", "readonly" => false }
|
|
104
|
+
},
|
|
105
|
+
"endpoints" => {},
|
|
106
|
+
"validations" => {
|
|
107
|
+
"name" => { "presence" => true, "length" => { "maximum" => 3 } },
|
|
108
|
+
"age" => { "presence" => true }
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
name_kinds = k.validators_on(:name).map(&:kind).sort
|
|
113
|
+
# presence appears once (client wins, schema presence deduped) + length merged
|
|
114
|
+
assert_equal [:length, :presence], name_kinds
|
|
115
|
+
assert_equal [:presence], k.validators_on(:age).map(&:kind)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def test_inline_attribute_validations_are_registered
|
|
119
|
+
# The shape produced by Funicular::Schema.build: validations nested inside
|
|
120
|
+
# each attribute entry.
|
|
121
|
+
k = Class.new(Funicular::Model)
|
|
122
|
+
k.load_schema(
|
|
123
|
+
"attributes" => {
|
|
124
|
+
"name" => {
|
|
125
|
+
"type" => "string", "readonly" => false,
|
|
126
|
+
"validations" => { "presence" => true, "length" => { "maximum" => 3 } }
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
"endpoints" => {}
|
|
130
|
+
)
|
|
131
|
+
assert_equal [:length, :presence], k.validators_on(:name).map(&:kind).sort
|
|
132
|
+
assert_equal false, k.new("name" => "toolong").valid?
|
|
133
|
+
assert_equal true, k.new("name" => "ok").valid?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def test_format_from_schema_rebuilds_regexp
|
|
137
|
+
k = Class.new(Funicular::Model)
|
|
138
|
+
k.load_schema(
|
|
139
|
+
"attributes" => { "name" => { "type" => "string", "readonly" => false } },
|
|
140
|
+
"endpoints" => {},
|
|
141
|
+
"validations" => { "name" => { "format" => { "with" => "^[a-z]+$", "flags" => "i" } } }
|
|
142
|
+
)
|
|
143
|
+
assert_equal true, k.new("name" => "ABC").valid? # 'i' flag honored
|
|
144
|
+
assert_equal false, k.new("name" => "123").valid?
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# --- create/update auto-validation -----------------------------------
|
|
148
|
+
|
|
149
|
+
def test_create_short_circuits_when_invalid
|
|
150
|
+
k = model_class({ "name" => false }, "create" => { "method" => "POST", "path" => "/things" })
|
|
151
|
+
k.class_eval { validates :name, presence: true }
|
|
152
|
+
|
|
153
|
+
got_instance = :unset
|
|
154
|
+
got_error = :unset
|
|
155
|
+
# Invalid -> returns before any HTTP call, yields the errors.
|
|
156
|
+
k.create({ "name" => "" }) do |instance, error|
|
|
157
|
+
got_instance = instance
|
|
158
|
+
got_error = error
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
assert_nil got_instance
|
|
162
|
+
assert_instance_of Funicular::Model::Errors, got_error
|
|
163
|
+
assert_equal ["can't be blank"], got_error[:name]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def test_update_short_circuits_when_invalid
|
|
167
|
+
k = model_class({ "name" => false }, "update" => { "method" => "PATCH", "path" => "/things/:id" })
|
|
168
|
+
k.class_eval { validates :name, presence: true }
|
|
169
|
+
|
|
170
|
+
m = k.new("name" => "ok")
|
|
171
|
+
m.instance_variable_set("@id", 1)
|
|
172
|
+
|
|
173
|
+
got_success = :unset
|
|
174
|
+
got_result = :unset
|
|
175
|
+
m.update("name" => "") do |success, result|
|
|
176
|
+
got_success = success
|
|
177
|
+
got_result = result
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
assert_equal false, got_success
|
|
181
|
+
assert_equal ["can't be blank"], got_result[:name]
|
|
182
|
+
end
|
|
183
|
+
end
|
data/mrbgem.rake
CHANGED
|
@@ -6,6 +6,7 @@ MRuby::Gem::Specification.new('picoruby-funicular') do |spec|
|
|
|
6
6
|
unless ENV['TEST_TASK']
|
|
7
7
|
spec.add_dependency 'picoruby-wasm'
|
|
8
8
|
end
|
|
9
|
+
spec.add_dependency 'picoruby-indexeddb'
|
|
9
10
|
spec.add_dependency 'picoruby-json'
|
|
10
11
|
spec.add_dependency 'mruby-object-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-object-ext"
|
|
11
12
|
spec.add_dependency 'mruby-hash-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-hash-ext"
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# Attribute validations for Funicular::Model, modeled on ActiveModel.
|
|
2
|
+
#
|
|
3
|
+
# This file is named with a "0_" prefix on purpose: the mruby build
|
|
4
|
+
# concatenates mrblib/**/*.rb in alphabetical order, so this loads before
|
|
5
|
+
# model.rb (which does `include Validations`) and before 1_validators.rb
|
|
6
|
+
# (the concrete validators). Keep this file free of Ruby-only regexp features
|
|
7
|
+
# (\A, \z, $1, ...): on the client, Regexp is a thin JS RegExp wrapper.
|
|
8
|
+
module Funicular
|
|
9
|
+
class Model
|
|
10
|
+
# A small slice of ActiveModel::Errors: messages keyed by attribute.
|
|
11
|
+
# Consumed by FormBuilder, which reads errors[:field].
|
|
12
|
+
class Errors
|
|
13
|
+
def initialize
|
|
14
|
+
@messages = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def add(attribute, message)
|
|
18
|
+
key = attribute.to_sym
|
|
19
|
+
(@messages[key] ||= []) << message
|
|
20
|
+
message
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Always returns an array (possibly empty) for the attribute.
|
|
24
|
+
def [](attribute)
|
|
25
|
+
@messages[attribute.to_sym] || []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def added?(attribute)
|
|
29
|
+
!self[attribute].empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# { attribute_symbol => ["message", ...] }
|
|
33
|
+
def messages
|
|
34
|
+
@messages
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def full_messages
|
|
38
|
+
result = [] #: Array[String]
|
|
39
|
+
@messages.each do |attribute, msgs|
|
|
40
|
+
human = humanize(attribute)
|
|
41
|
+
msgs.each { |m| result << "#{human} #{m}" }
|
|
42
|
+
end
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def clear
|
|
47
|
+
@messages = {}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def empty?
|
|
51
|
+
@messages.all? { |_attr, msgs| msgs.empty? }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def any?
|
|
55
|
+
!empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def humanize(attribute)
|
|
61
|
+
words = attribute.to_s.split("_")
|
|
62
|
+
first = words[0].to_s
|
|
63
|
+
first = first[0].to_s.upcase + first[1..-1].to_s
|
|
64
|
+
([first] + words[1..-1].to_a).join(" ")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
module Validations
|
|
69
|
+
def self.included(base)
|
|
70
|
+
base.extend(ClassMethods)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Base class for attribute validators, mirroring
|
|
74
|
+
# ActiveModel::Validations::EachValidator.
|
|
75
|
+
class EachValidator
|
|
76
|
+
attr_reader :attributes, :options
|
|
77
|
+
|
|
78
|
+
def initialize(attributes:, **options)
|
|
79
|
+
@attributes = attributes
|
|
80
|
+
@options = options
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# PresenceValidator -> :presence. Validator names are single words,
|
|
84
|
+
# so a plain downcase is enough (no regexp needed).
|
|
85
|
+
def kind
|
|
86
|
+
name = self.class.to_s.split("::").last.to_s
|
|
87
|
+
name = name[0...-9] if name.end_with?("Validator")
|
|
88
|
+
name.to_s.downcase.to_sym
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate(record)
|
|
92
|
+
attributes.each do |attribute|
|
|
93
|
+
value = record.read_attribute_for_validation(attribute)
|
|
94
|
+
next if options[:allow_nil] && value.nil?
|
|
95
|
+
next if options[:allow_blank] && blank?(value)
|
|
96
|
+
validate_each(record, attribute, value)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def validate_each(record, attribute, value)
|
|
101
|
+
raise "#{self.class} must implement validate_each"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def blank?(value)
|
|
107
|
+
return true if value.nil?
|
|
108
|
+
return value.strip.empty? if value.is_a?(String)
|
|
109
|
+
return value.empty? if value.respond_to?(:empty?)
|
|
110
|
+
false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def present?(value)
|
|
114
|
+
!blank?(value)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
module ClassMethods
|
|
119
|
+
# Validators declared on this model class.
|
|
120
|
+
def validators
|
|
121
|
+
@validators ||= []
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def validators_on(attribute)
|
|
125
|
+
attr = attribute.to_sym
|
|
126
|
+
validators.select { |v| v.attributes.include?(attr) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Options that configure every validator in a `validates` call rather
|
|
130
|
+
# than naming a validator of their own (mirrors ActiveModel).
|
|
131
|
+
SHARED_OPTIONS = [:allow_nil, :allow_blank, :if, :unless, :on, :strict]
|
|
132
|
+
|
|
133
|
+
# validates :a, :b, presence: true, length: { maximum: 30 }, allow_blank: true
|
|
134
|
+
def validates(*attributes, **options)
|
|
135
|
+
return if attributes.empty?
|
|
136
|
+
attrs = attributes.map { |a| a.to_sym }
|
|
137
|
+
|
|
138
|
+
shared = {} #: Hash[Symbol, untyped]
|
|
139
|
+
SHARED_OPTIONS.each { |k| shared[k] = options[k] if options.key?(k) }
|
|
140
|
+
|
|
141
|
+
options.each do |key, opts|
|
|
142
|
+
next if SHARED_OPTIONS.include?(key)
|
|
143
|
+
klass = validator_class_for(key)
|
|
144
|
+
next unless klass
|
|
145
|
+
base = {} #: Hash[Symbol, untyped]
|
|
146
|
+
base = opts unless opts == true
|
|
147
|
+
validator_options = shared.merge(base) # per-validator options win
|
|
148
|
+
validators << klass.new(attributes: attrs, **validator_options)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Append a single schema-derived validator (from load_schema), unless a
|
|
153
|
+
# validator of the same kind is already declared for the attribute in
|
|
154
|
+
# the subclass body. Frontend declarations win, avoiding duplicates.
|
|
155
|
+
def add_schema_validator(attribute, kind, opts)
|
|
156
|
+
attr = attribute.to_sym
|
|
157
|
+
k = kind.to_sym
|
|
158
|
+
return if validators.any? { |v| v.attributes.include?(attr) && v.kind == k }
|
|
159
|
+
klass = validator_class_for(kind)
|
|
160
|
+
return unless klass
|
|
161
|
+
validator_options = {} #: Hash[Symbol, untyped]
|
|
162
|
+
validator_options = symbolize_keys(opts) unless opts == true
|
|
163
|
+
validators << klass.new(attributes: [attr], **validator_options)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def validator_class_for(key)
|
|
169
|
+
class_name = "#{key.to_s.capitalize}Validator"
|
|
170
|
+
return nil unless Funicular::Model::Validations.const_defined?(class_name)
|
|
171
|
+
Funicular::Model::Validations.const_get(class_name)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def symbolize_keys(hash)
|
|
175
|
+
return hash unless hash.is_a?(Hash)
|
|
176
|
+
out = {} #: Hash[Symbol, untyped]
|
|
177
|
+
hash.each { |k, v| out[k.to_sym] = v }
|
|
178
|
+
out
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# --- instance API ---
|
|
183
|
+
|
|
184
|
+
def errors
|
|
185
|
+
@errors ||= Funicular::Model::Errors.new
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def read_attribute_for_validation(attribute)
|
|
189
|
+
send(attribute)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def valid?
|
|
193
|
+
errors.clear
|
|
194
|
+
# Steep does not know whether `self.class` extends ClassMethods
|
|
195
|
+
# @type var cls: untyped
|
|
196
|
+
cls = self.class
|
|
197
|
+
cls.validators.each { |validator| validator.validate(self) }
|
|
198
|
+
errors.empty?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def invalid?
|
|
202
|
+
!valid?
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Concrete validators for Funicular::Model, mirroring the standard Rails set.
|
|
2
|
+
#
|
|
3
|
+
# Named "1_" so the mruby build loads it after 0_validations.rb (which defines
|
|
4
|
+
# EachValidator) but before model.rb. Keep this free of Ruby-only regexp
|
|
5
|
+
# features: on the client, Regexp is a JS RegExp wrapper.
|
|
6
|
+
module Funicular
|
|
7
|
+
class Model
|
|
8
|
+
module Validations
|
|
9
|
+
class PresenceValidator < EachValidator
|
|
10
|
+
def validate_each(record, attribute, value)
|
|
11
|
+
record.errors.add(attribute, options[:message] || "can't be blank") if blank?(value)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class AbsenceValidator < EachValidator
|
|
16
|
+
def validate_each(record, attribute, value)
|
|
17
|
+
record.errors.add(attribute, options[:message] || "must be blank") if present?(value)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class LengthValidator < EachValidator
|
|
22
|
+
def validate_each(record, attribute, value)
|
|
23
|
+
length = value.nil? ? 0 : value.to_s.length
|
|
24
|
+
|
|
25
|
+
if (min = options[:minimum]) && length < min
|
|
26
|
+
record.errors.add(attribute, options[:message] ||
|
|
27
|
+
"is too short (minimum is #{min} characters)")
|
|
28
|
+
end
|
|
29
|
+
if (max = options[:maximum]) && length > max
|
|
30
|
+
record.errors.add(attribute, options[:message] ||
|
|
31
|
+
"is too long (maximum is #{max} characters)")
|
|
32
|
+
end
|
|
33
|
+
if (exact = options[:is]) && length != exact
|
|
34
|
+
record.errors.add(attribute, options[:message] ||
|
|
35
|
+
"is the wrong length (should be #{exact} characters)")
|
|
36
|
+
end
|
|
37
|
+
if (range = options[:in] || options[:within]) && !range_include?(range, length)
|
|
38
|
+
record.errors.add(attribute, options[:message] || "is the wrong length")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def range_include?(range, length)
|
|
45
|
+
if range.respond_to?(:include?)
|
|
46
|
+
range.include?(length)
|
|
47
|
+
else
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class FormatValidator < EachValidator
|
|
54
|
+
def validate_each(record, attribute, value)
|
|
55
|
+
str = value.to_s
|
|
56
|
+
if (with = options[:with]) && !str.match?(with)
|
|
57
|
+
record.errors.add(attribute, options[:message] || "is invalid")
|
|
58
|
+
end
|
|
59
|
+
if (without = options[:without]) && str.match?(without)
|
|
60
|
+
record.errors.add(attribute, options[:message] || "is invalid")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class NumericalityValidator < EachValidator
|
|
66
|
+
CHECKS = {
|
|
67
|
+
greater_than: ">",
|
|
68
|
+
greater_than_or_equal_to: ">=",
|
|
69
|
+
equal_to: "==",
|
|
70
|
+
less_than: "<",
|
|
71
|
+
less_than_or_equal_to: "<=",
|
|
72
|
+
other_than: "!="
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def validate_each(record, attribute, value)
|
|
76
|
+
number = to_number(value)
|
|
77
|
+
if number.nil?
|
|
78
|
+
record.errors.add(attribute, options[:message] || "is not a number")
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if options[:only_integer] && !number.is_a?(Integer)
|
|
83
|
+
record.errors.add(attribute, options[:message] || "must be an integer")
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
CHECKS.each do |option, _operator|
|
|
88
|
+
next unless options.key?(option)
|
|
89
|
+
unless compare(number, option, options[option])
|
|
90
|
+
record.errors.add(attribute, options[:message] || message_for(option, options[option]))
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def compare(number, option, target)
|
|
98
|
+
case option
|
|
99
|
+
when :greater_than then number > target
|
|
100
|
+
when :greater_than_or_equal_to then number >= target
|
|
101
|
+
when :equal_to then number == target
|
|
102
|
+
when :less_than then number < target
|
|
103
|
+
when :less_than_or_equal_to then number <= target
|
|
104
|
+
when :other_than then number != target
|
|
105
|
+
else true
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def message_for(option, target)
|
|
110
|
+
case option
|
|
111
|
+
when :greater_than then "must be greater than #{target}"
|
|
112
|
+
when :greater_than_or_equal_to then "must be greater than or equal to #{target}"
|
|
113
|
+
when :equal_to then "must be equal to #{target}"
|
|
114
|
+
when :less_than then "must be less than #{target}"
|
|
115
|
+
when :less_than_or_equal_to then "must be less than or equal to #{target}"
|
|
116
|
+
when :other_than then "must be other than #{target}"
|
|
117
|
+
else "is invalid"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def to_number(value)
|
|
122
|
+
return value if value.is_a?(Numeric)
|
|
123
|
+
str = value.to_s.strip
|
|
124
|
+
return nil if str.empty?
|
|
125
|
+
begin
|
|
126
|
+
Integer(str)
|
|
127
|
+
rescue
|
|
128
|
+
begin
|
|
129
|
+
Float(str)
|
|
130
|
+
rescue
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
class InclusionValidator < EachValidator
|
|
138
|
+
def validate_each(record, attribute, value)
|
|
139
|
+
list = options[:in] || options[:within]
|
|
140
|
+
return unless list.respond_to?(:include?)
|
|
141
|
+
unless list.include?(value)
|
|
142
|
+
record.errors.add(attribute, options[:message] || "is not included in the list")
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
class ExclusionValidator < EachValidator
|
|
148
|
+
def validate_each(record, attribute, value)
|
|
149
|
+
list = options[:in] || options[:within]
|
|
150
|
+
return unless list.respond_to?(:include?)
|
|
151
|
+
if list.include?(value)
|
|
152
|
+
record.errors.add(attribute, options[:message] || "is reserved")
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
class AcceptanceValidator < EachValidator
|
|
158
|
+
def validate_each(record, attribute, value)
|
|
159
|
+
accepted = options[:accept] || ["1", "true", true]
|
|
160
|
+
accepted = [accepted] unless accepted.is_a?(Array)
|
|
161
|
+
unless accepted.include?(value)
|
|
162
|
+
record.errors.add(attribute, options[:message] || "must be accepted")
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
class ConfirmationValidator < EachValidator
|
|
168
|
+
def validate_each(record, attribute, value)
|
|
169
|
+
confirmation_attr = "#{attribute}_confirmation"
|
|
170
|
+
return unless record.respond_to?(confirmation_attr)
|
|
171
|
+
confirmation = record.send(confirmation_attr)
|
|
172
|
+
return if confirmation.nil?
|
|
173
|
+
if value != confirmation
|
|
174
|
+
record.errors.add(attribute, options[:message] || "doesn't match confirmation")
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
data/mrblib/cable.rb
CHANGED
|
@@ -38,12 +38,18 @@ module Funicular
|
|
|
38
38
|
# puts "[Cable] WebSocket object created, setting up handlers"
|
|
39
39
|
|
|
40
40
|
@websocket.onopen do |event|
|
|
41
|
+
was_reconnect = @reconnect_attempts > 0
|
|
41
42
|
@connected = true
|
|
42
43
|
@reconnect_attempts = 0
|
|
43
44
|
# puts "[Cable] Connected to #{@url}"
|
|
44
45
|
# Delay flush to ensure connection is stable
|
|
45
46
|
JS.global.setTimeout(100) do
|
|
46
|
-
|
|
47
|
+
next unless @connected
|
|
48
|
+
# On reconnect the server has forgotten our subscriptions; re-issue
|
|
49
|
+
# subscribe commands before flushing any pending perform/unsubscribe
|
|
50
|
+
# so the server knows about them when those messages arrive.
|
|
51
|
+
@subscriptions.resubscribe_all if was_reconnect
|
|
52
|
+
flush_pending_commands
|
|
47
53
|
end
|
|
48
54
|
end
|
|
49
55
|
|
|
@@ -233,7 +239,8 @@ module Funicular
|
|
|
233
239
|
return if @pending_commands.empty?
|
|
234
240
|
begin
|
|
235
241
|
json = JSON.generate(@pending_commands)
|
|
236
|
-
JS.global[:localStorage]
|
|
242
|
+
storage = JS.global[:localStorage]
|
|
243
|
+
storage.setItem(STORAGE_KEY, json) if storage.is_a?(JS::Object)
|
|
237
244
|
rescue => e
|
|
238
245
|
puts "[Cable] Error saving to localStorage: #{e.message}"
|
|
239
246
|
end
|
|
@@ -242,12 +249,10 @@ module Funicular
|
|
|
242
249
|
# Load pending commands from localStorage
|
|
243
250
|
def load_pending_from_storage
|
|
244
251
|
begin
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
[]
|
|
250
|
-
end
|
|
252
|
+
storage = JS.global[:localStorage]
|
|
253
|
+
return [] unless storage.is_a?(JS::Object)
|
|
254
|
+
stored = storage.getItem(STORAGE_KEY)
|
|
255
|
+
stored.is_a?(String) ? JSON.parse(stored) : []
|
|
251
256
|
rescue => e
|
|
252
257
|
puts "[Cable] Error loading from localStorage: #{e.message}"
|
|
253
258
|
[]
|
|
@@ -257,7 +262,8 @@ module Funicular
|
|
|
257
262
|
# Clear pending commands from localStorage
|
|
258
263
|
def clear_pending_storage
|
|
259
264
|
begin
|
|
260
|
-
JS.global[:localStorage]
|
|
265
|
+
storage = JS.global[:localStorage]
|
|
266
|
+
storage.removeItem(STORAGE_KEY) if storage.is_a?(JS::Object)
|
|
261
267
|
rescue => e
|
|
262
268
|
puts "[Cable] Error clearing localStorage: #{e.message}"
|
|
263
269
|
end
|
|
@@ -316,6 +322,15 @@ module Funicular
|
|
|
316
322
|
return unless subscription
|
|
317
323
|
subscription.notify_received(message)
|
|
318
324
|
end
|
|
325
|
+
|
|
326
|
+
# Re-issue subscribe for every active subscription. Used after a
|
|
327
|
+
# WebSocket reconnect, where the server has dropped its subscription
|
|
328
|
+
# state but the client-side Subscription objects are still valid.
|
|
329
|
+
def resubscribe_all
|
|
330
|
+
@subscriptions.each_value do |subscription|
|
|
331
|
+
subscription.subscribe
|
|
332
|
+
end
|
|
333
|
+
end
|
|
319
334
|
end
|
|
320
335
|
|
|
321
336
|
# Subscription represents a subscription to a specific channel
|