funicular 0.0.1 → 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 +79 -0
- data/README.md +66 -20
- data/Rakefile +103 -2
- data/demo/keymap_editor.html +582 -0
- data/demo/test_cable.html +179 -0
- data/demo/test_chartjs.html +235 -0
- data/demo/test_component.html +201 -0
- data/demo/test_diff_patch.html +146 -0
- data/demo/test_error_boundary.html +284 -0
- data/demo/test_router.html +257 -0
- data/demo/test_vdom.html +100 -0
- data/demo/tic-tac-toe.html +201 -0
- data/docs/architecture.md +118 -0
- data/exe/funicular +32 -0
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/assets/funicular.rb +21 -0
- data/lib/funicular/assets/funicular_debug.css +73 -0
- data/lib/funicular/assets/funicular_debug.js +183 -0
- data/lib/funicular/commands/routes.rb +69 -0
- data/lib/funicular/compiler.rb +143 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +112 -0
- data/lib/funicular/middleware.rb +123 -0
- data/lib/funicular/plugin.rb +147 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -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/VERSION +1 -0
- data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -0
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +6423 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
- 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 +32 -1
- 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 +218 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/funicular_test.rb +13 -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/test_helper.rb +7 -0
- data/minitest/validations_test.rb +183 -0
- data/mrbgem.rake +16 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +432 -0
- data/mrblib/component.rb +1050 -0
- data/mrblib/debug.rb +208 -0
- data/mrblib/differ.rb +254 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +192 -0
- data/mrblib/form_builder.rb +300 -0
- data/mrblib/funicular.rb +245 -0
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +183 -0
- data/mrblib/model.rb +196 -0
- data/mrblib/patcher.rb +269 -0
- data/mrblib/router.rb +266 -0
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +66 -0
- data/sig/component.rbs +149 -0
- data/sig/debug.rbs +28 -0
- data/sig/differ.rbs +18 -0
- data/sig/environment_iquirer.rbs +10 -0
- data/sig/error_boundary.rbs +14 -0
- data/sig/file_upload.rbs +18 -0
- data/sig/form_builder.rbs +29 -0
- data/sig/funicular.rbs +24 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +37 -0
- data/sig/model.rbs +28 -0
- data/sig/patcher.rbs +18 -0
- data/sig/router.rbs +44 -0
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/styles.rbs +25 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +59 -0
- metadata +154 -8
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# Exercises the SSR half of Funicular under CRuby: the shared HTMLSerializer
|
|
6
|
+
# and the full path of loading the mrblib runtime + a fixture app, then
|
|
7
|
+
# rendering a routed component to HTML with injected state.
|
|
8
|
+
class SSRTest < Minitest::Test
|
|
9
|
+
APP_DIR = File.expand_path("fixtures/funicular_app", __dir__)
|
|
10
|
+
|
|
11
|
+
def setup
|
|
12
|
+
Funicular::SSR::Runtime.load_framework!
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# --- HTMLSerializer (pure Ruby) ---------------------------------------
|
|
16
|
+
|
|
17
|
+
def serialize(vnode)
|
|
18
|
+
Funicular::VDOM::HTMLSerializer.serialize(vnode)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def el(tag, props = {}, children = [])
|
|
22
|
+
Funicular::VDOM::Element.new(tag, props, children)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def test_serializes_element_with_attributes
|
|
26
|
+
assert_equal '<div class="box" id="x"></div>',
|
|
27
|
+
serialize(el("div", { class: "box", id: "x" }))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_escapes_text_content
|
|
31
|
+
assert_equal "<p>a & b <c></p>",
|
|
32
|
+
serialize(el("p", {}, ["a & b <c>"]))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_escapes_attribute_values
|
|
36
|
+
assert_equal '<div title=""hi""></div>',
|
|
37
|
+
serialize(el("div", { title: '"hi"' }))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def test_skips_event_handlers
|
|
41
|
+
html = serialize(el("button", { onclick: :handle }, ["Go"]))
|
|
42
|
+
assert_equal "<button>Go</button>", html
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def test_boolean_attribute_present_and_absent
|
|
46
|
+
assert_equal '<input disabled="disabled">',
|
|
47
|
+
serialize(el("input", { disabled: true }))
|
|
48
|
+
assert_equal "<input>",
|
|
49
|
+
serialize(el("input", { disabled: false }))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def test_void_element_self_closes
|
|
53
|
+
assert_equal "<br>", serialize(el("br"))
|
|
54
|
+
assert_equal '<img src="/a.png">', serialize(el("img", { src: "/a.png" }))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def test_blocks_javascript_uri
|
|
58
|
+
assert_equal "<a>x</a>",
|
|
59
|
+
serialize(el("a", { href: "javascript:alert(1)" }, ["x"]))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# --- Full SSR render (runtime + fixture app) --------------------------
|
|
63
|
+
|
|
64
|
+
def test_render_injects_server_state
|
|
65
|
+
result = Funicular::SSR.render(
|
|
66
|
+
path: "/greet",
|
|
67
|
+
state: { title: "Channels", items: [{ "id" => 1, "name" => "general" },
|
|
68
|
+
{ "id" => 2, "name" => "random" }] },
|
|
69
|
+
source_dir: APP_DIR
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
assert_includes result[:html], "<h1>Channels</h1>"
|
|
73
|
+
assert_includes result[:html], "general"
|
|
74
|
+
assert_includes result[:html], "random"
|
|
75
|
+
assert_equal GreetingComponent, result[:component]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def test_render_uses_initialize_state_without_injection
|
|
79
|
+
result = Funicular::SSR.render(path: "/greet", source_dir: APP_DIR)
|
|
80
|
+
assert_includes result[:html], "<h1>Default Title</h1>"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def test_render_unmatched_route_returns_empty
|
|
84
|
+
result = Funicular::SSR.render(path: "/no/such/path", source_dir: APP_DIR)
|
|
85
|
+
assert_equal "", result[:html]
|
|
86
|
+
assert_nil result[:component]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def test_route_params_become_props
|
|
90
|
+
# Renders without raising; :id from the path is passed as a prop.
|
|
91
|
+
result = Funicular::SSR.render(path: "/greet/42", source_dir: APP_DIR)
|
|
92
|
+
assert_includes result[:html], "greeting"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -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
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
MRuby::Gem::Specification.new('picoruby-funicular') do |spec|
|
|
2
|
+
spec.license = 'MIT'
|
|
3
|
+
spec.author = 'HASUMI Hitoshi'
|
|
4
|
+
spec.summary = 'Browser application framework with VDOM for PicoRuby.wasm'
|
|
5
|
+
|
|
6
|
+
unless ENV['TEST_TASK']
|
|
7
|
+
spec.add_dependency 'picoruby-wasm'
|
|
8
|
+
end
|
|
9
|
+
spec.add_dependency 'picoruby-indexeddb'
|
|
10
|
+
spec.add_dependency 'picoruby-json'
|
|
11
|
+
spec.add_dependency 'mruby-object-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-object-ext"
|
|
12
|
+
spec.add_dependency 'mruby-hash-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-hash-ext"
|
|
13
|
+
spec.add_dependency 'mruby-array-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-array-ext"
|
|
14
|
+
spec.add_dependency 'mruby-string-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-string-ext"
|
|
15
|
+
spec.add_dependency 'mruby-metaprog', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-metaprog"
|
|
16
|
+
end
|
|
@@ -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
|