hakumi_components 0.1.16.pre → 0.1.17.pre
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/README.md +169 -23
- data/app/assets/javascripts/hakumi_components.js +12 -12
- data/app/assets/stylesheets/hakumi_components.css +1 -1
- data/app/components/hakumi/alert/component.html.erb +12 -8
- data/app/components/hakumi/alert/component.rb +18 -62
- data/app/components/hakumi/base_component.rb +13 -0
- data/app/components/hakumi/card/component.html.erb +14 -22
- data/app/components/hakumi/card/component.rb +38 -31
- data/app/components/hakumi/checkbox/component.html.erb +39 -21
- data/app/components/hakumi/checkbox/component.rb +12 -2
- data/app/components/hakumi/collapse/component.html.erb +2 -2
- data/app/components/hakumi/collapse/component.rb +1 -1
- data/app/components/hakumi/collapse/panel/component.rb +9 -0
- data/app/components/hakumi/color_picker/component.rb +0 -4
- data/app/components/hakumi/drawer/component.html.erb +7 -7
- data/app/components/hakumi/drawer/component.rb +12 -19
- data/app/components/hakumi/input/component.rb +0 -2
- data/app/components/hakumi/input/text_area/component.rb +0 -2
- data/app/components/hakumi/input_number/component.rb +3 -4
- data/app/components/hakumi/mentions/component.rb +0 -1
- data/app/components/hakumi/modal/component.html.erb +40 -0
- data/app/components/hakumi/modal/component.rb +24 -102
- data/app/components/hakumi/modal/confirm/component.html.erb +23 -0
- data/app/components/hakumi/modal/confirm/component.rb +23 -41
- data/app/components/hakumi/modal/error/component.rb +12 -11
- data/app/components/hakumi/modal/info/component.rb +12 -11
- data/app/components/hakumi/modal/success/component.rb +12 -11
- data/app/components/hakumi/modal/warning/component.rb +15 -10
- data/app/components/hakumi/popconfirm/component.html.erb +25 -25
- data/app/components/hakumi/popconfirm/component.rb +11 -27
- data/app/components/hakumi/rate/component.rb +0 -1
- data/app/components/hakumi/segmented/component.rb +0 -4
- data/app/components/hakumi/slider/component.rb +2 -6
- data/app/components/hakumi/statistic/component.rb +0 -4
- data/app/components/hakumi/switch/component.html.erb +4 -0
- data/app/components/hakumi/switch/component.rb +1 -2
- data/app/components/hakumi/table/component.rb +3 -229
- data/app/components/hakumi/table/concerns/columns.rb +1 -1
- data/app/components/hakumi/table/concerns/editable.rb +121 -0
- data/app/components/hakumi/table/concerns/ellipsis.rb +63 -0
- data/app/components/hakumi/table/concerns/fixed_columns.rb +87 -0
- data/app/components/hakumi/transfer/component.rb +0 -4
- data/app/controllers/{hakumi_components → hakumi}/components_controller.rb +2 -2
- data/app/form_builders/hakumi/form_builder.rb +217 -175
- data/app/helpers/hakumi/form_helper.rb +39 -0
- data/app/javascript/hakumi_components/controllers/base/registry_controller.js +83 -3
- data/app/javascript/hakumi_components/controllers/hakumi/affix_controller.js +0 -23
- data/app/javascript/hakumi_components/controllers/hakumi/alert_controller.js +2 -1
- data/app/javascript/hakumi_components/controllers/hakumi/button_controller.js +0 -7
- data/app/javascript/hakumi_components/controllers/hakumi/calendar_controller.js +0 -2
- data/app/javascript/hakumi_components/controllers/hakumi/color_picker_controller.js +1 -6
- data/app/javascript/hakumi_components/controllers/hakumi/date_picker_controller.js +28 -34
- data/app/javascript/hakumi_components/controllers/hakumi/drawer_controller.js +2 -1
- data/app/javascript/hakumi_components/controllers/hakumi/form_item_controller.js +9 -63
- data/app/javascript/hakumi_components/controllers/hakumi/mentions_controller.js +4 -11
- data/app/javascript/hakumi_components/controllers/hakumi/message_controller.js +1 -1
- data/app/javascript/hakumi_components/controllers/hakumi/modal_controller.js +4 -20
- data/app/javascript/hakumi_components/controllers/hakumi/notification_controller.js +1 -1
- data/app/javascript/hakumi_components/controllers/hakumi/popconfirm_controller.js +33 -27
- data/app/javascript/hakumi_components/controllers/hakumi/popover_controller.js +2 -23
- data/app/javascript/hakumi_components/controllers/hakumi/qr_code_controller.js +0 -20
- data/app/javascript/hakumi_components/controllers/hakumi/segmented_controller.js +0 -2
- data/app/javascript/hakumi_components/controllers/hakumi/spin_controller.js +1 -19
- data/app/javascript/hakumi_components/controllers/hakumi/statistic_controller.js +0 -2
- data/app/javascript/hakumi_components/controllers/hakumi/table_controller.js +48 -74
- data/app/javascript/hakumi_components/controllers/hakumi/tag_controller.js +15 -14
- data/app/javascript/hakumi_components/controllers/hakumi/tag_group_controller.js +14 -13
- data/app/javascript/hakumi_components/controllers/hakumi/theme_controller.js +24 -1
- data/app/javascript/hakumi_components/controllers/hakumi/time_picker_controller.js +3 -7
- data/app/javascript/hakumi_components/controllers/hakumi/timeline_controller.js +0 -16
- data/app/javascript/hakumi_components/controllers/hakumi/transfer_controller.js +2 -2
- data/app/javascript/hakumi_components/controllers/hakumi/tree_controller.js +0 -2
- data/app/javascript/hakumi_components/controllers/hakumi/tree_select_controller.js +3 -3
- data/app/javascript/hakumi_components/controllers/hakumi/upload_controller.js +12 -26
- data/app/javascript/hakumi_components/core/persistence.js +3 -3
- data/app/javascript/hakumi_components/core/render_component.js +3 -1
- data/app/javascript/lib/validation_manager.js +101 -0
- data/app/javascript/stylesheets/_theme-tokens.scss +2 -1
- data/app/javascript/stylesheets/components/_modal.scss +13 -0
- data/app/services/{hakumi_components → hakumi}/component_handler.rb +1 -1
- data/app/services/hakumi/icon/loader.rb +2 -2
- data/app/services/hakumi/illustrations/loader.rb +3 -3
- data/app/views/hakumi/_drawer.html.erb +21 -0
- data/app/views/hakumi/_modal.html.erb +18 -0
- data/lib/hakumi_components/documentation.rb +127 -0
- data/lib/hakumi_components/engine.rb +13 -4
- data/lib/hakumi_components/rails/attribute_introspection.rb +1 -1
- data/lib/hakumi_components/rails/validation_introspection.rb +5 -5
- data/lib/hakumi_components/rails/validation_mapper.rb +484 -0
- data/lib/hakumi_components/rails.rb +2 -1
- data/lib/hakumi_components/version.rb +2 -2
- data/lib/hakumi_components.rb +3 -1
- data/lib/tasks/coverage.rake +37 -0
- data/sig/hakumi/base_component.rbs +5 -0
- data/sig/hakumi/checkbox/component.rbs +10 -0
- data/sig/hakumi/color_picker/component.rbs +0 -1
- data/sig/hakumi/form_builder.rbs +9 -1
- data/sig/{hakumi_components → hakumi}/rails/attribute_introspection.rbs +1 -1
- data/sig/{hakumi_components → hakumi}/rails/validation_introspection.rbs +1 -1
- data/sig/hakumi/rails/validation_mapper.rbs +53 -0
- data/sig/{hakumi_components → hakumi}/rails.rbs +1 -1
- data/sig/hakumi/segmented/component.rbs +0 -1
- data/sig/hakumi/slider/component.rbs +0 -1
- data/sig/hakumi/statistic/component.rbs +0 -2
- data/sig/hakumi/table/component.rbs +3 -4
- data/sig/hakumi/table/concerns/columns.rbs +2 -1
- data/sig/hakumi/table/concerns/editable.rbs +40 -0
- data/sig/hakumi/table/concerns/ellipsis.rbs +27 -0
- data/sig/hakumi/table/concerns/fixed_columns.rbs +33 -0
- data/sig/hakumi/transfer/component.rbs +0 -1
- data/sig/{hakumi_components.rbs → hakumi.rbs} +20 -3
- data/sig/rails/active_model/validations/comparison_validator.rbs +6 -0
- metadata +44 -29
- data/app/views/hakumi_components/_drawer.html.erb +0 -3
- data/app/views/hakumi_components/_modal.html.erb +0 -3
- /data/app/views/{hakumi_components → hakumi}/_admin_panel.html.erb +0 -0
- /data/app/views/{hakumi_components → hakumi}/_affix.html.erb +0 -0
- /data/app/views/{hakumi_components → hakumi}/_alert.html.erb +0 -0
- /data/app/views/{hakumi_components → hakumi}/_confirm.html.erb +0 -0
- /data/app/views/{hakumi_components → hakumi}/_message.html.erb +0 -0
- /data/app/views/{hakumi_components → hakumi}/_notification.html.erb +0 -0
- /data/app/views/{hakumi_components → hakumi}/_popconfirm.html.erb +0 -0
- /data/app/views/{hakumi_components → hakumi}/_popover.html.erb +0 -0
- /data/app/views/{hakumi_components → hakumi}/_qr_code.html.erb +0 -0
- /data/app/views/{hakumi_components → hakumi}/_result.html.erb +0 -0
- /data/app/views/{hakumi_components → hakumi}/_segmented.html.erb +0 -0
- /data/app/views/{hakumi_components → hakumi}/_skeleton.html.erb +0 -0
- /data/app/views/{hakumi_components → hakumi}/_spin.html.erb +0 -0
- /data/app/views/{hakumi_components → hakumi}/_statistic.html.erb +0 -0
- /data/app/views/{hakumi_components → hakumi}/_table.html.erb +0 -0
- /data/app/views/{hakumi_components → hakumi}/_tag.html.erb +0 -0
- /data/app/views/{hakumi_components → hakumi}/_timeline.html.erb +0 -0
- /data/app/views/{hakumi_components → hakumi}/_tree.html.erb +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module Hakumi
|
|
4
4
|
module Rails
|
|
5
5
|
# Introspects ActiveModel/ActiveRecord validations to automatically
|
|
6
6
|
# configure form field HTML attributes and client-side validation
|
|
@@ -77,8 +77,8 @@ module HakumiComponents
|
|
|
77
77
|
return nil unless regex.is_a?(Regexp)
|
|
78
78
|
|
|
79
79
|
# Convert Ruby regex to JavaScript-compatible pattern
|
|
80
|
-
# Remove anchors ^$ as HTML pattern
|
|
81
|
-
regex.source.gsub(/\A
|
|
80
|
+
# Remove anchors (both Ruby \A\z and PCRE ^$) as HTML pattern adds them implicitly
|
|
81
|
+
regex.source.gsub(/\A(\\A|\^)|(\$|\\z)\z/, "")
|
|
82
82
|
end
|
|
83
83
|
|
|
84
84
|
# Get min/max values from numericality validation
|
|
@@ -96,7 +96,7 @@ module HakumiComponents
|
|
|
96
96
|
return {} unless numericality_validator
|
|
97
97
|
|
|
98
98
|
# @type var constraints: Hash[Symbol, Object]
|
|
99
|
-
constraints =
|
|
99
|
+
constraints = Hash.new
|
|
100
100
|
opts = validator_options(numericality_validator)
|
|
101
101
|
|
|
102
102
|
min_inclusive = opts[:greater_than_or_equal_to]
|
|
@@ -182,7 +182,7 @@ module HakumiComponents
|
|
|
182
182
|
|
|
183
183
|
def validator_options(validator)
|
|
184
184
|
# @type var opts: Hash[Symbol, Object]
|
|
185
|
-
opts = validator.respond_to?(:options) ? validator.public_send(:options) :
|
|
185
|
+
opts = validator.respond_to?(:options) ? validator.public_send(:options) : Hash.new
|
|
186
186
|
opts = opts.to_h if opts.respond_to?(:to_h)
|
|
187
187
|
opts.is_a?(Hash) ? opts : {}
|
|
188
188
|
end
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hakumi
|
|
4
|
+
module Rails
|
|
5
|
+
# Maps Rails ActiveModel validations to frontend validation rules
|
|
6
|
+
# compatible with Hakumi's ValidationManager (JavaScript)
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# class User < ApplicationRecord
|
|
10
|
+
# validates :email, presence: true, length: { maximum: 255 }
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# ValidationMapper.to_frontend_rules(user, :email)
|
|
14
|
+
# # => [
|
|
15
|
+
# # { required: true, message: "Email can't be blank" },
|
|
16
|
+
# # { maxLength: { value: 255 }, message: "Email is too long (maximum is 255 characters)" }
|
|
17
|
+
# # ]
|
|
18
|
+
class ValidationMapper
|
|
19
|
+
class << self
|
|
20
|
+
# Convert Rails validations to frontend validation rules
|
|
21
|
+
#
|
|
22
|
+
# @param object [ActiveModel::Model, ActiveRecord::Base]
|
|
23
|
+
# @param attribute [Symbol] Attribute name
|
|
24
|
+
# @return [Array<Hash>] Array of validation rules for frontend
|
|
25
|
+
def to_frontend_rules(object, attribute)
|
|
26
|
+
return [] unless object
|
|
27
|
+
return [] unless object.class.respond_to?(:validators_on)
|
|
28
|
+
|
|
29
|
+
validators = object.class.validators_on(attribute)
|
|
30
|
+
return [] if validators.empty?
|
|
31
|
+
|
|
32
|
+
rules = Array.new
|
|
33
|
+
|
|
34
|
+
validators.each do |validator|
|
|
35
|
+
mapped_rules = map_validator(validator, object, attribute)
|
|
36
|
+
rules.concat(mapped_rules) if mapped_rules
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
rules.compact
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Merge manual rules with auto-detected rules
|
|
43
|
+
# Manual rules take priority over auto-detected ones for the same validation type
|
|
44
|
+
#
|
|
45
|
+
# @param manual_rules [Array<Hash>, nil] User-provided rules
|
|
46
|
+
# @param auto_rules [Array<Hash>] Auto-detected rules from validators
|
|
47
|
+
# @return [Array<Hash>] Merged rules with manual rules taking priority
|
|
48
|
+
def merge_rules(manual_rules, auto_rules)
|
|
49
|
+
return auto_rules if manual_rules.nil? || manual_rules.empty?
|
|
50
|
+
return manual_rules if auto_rules.nil? || auto_rules.empty?
|
|
51
|
+
|
|
52
|
+
# Get rule types from manual rules
|
|
53
|
+
manual_types = manual_rules.map { |rule| extract_rule_type(rule) }.compact
|
|
54
|
+
|
|
55
|
+
# Filter out auto-detected rules that have the same type as manual rules
|
|
56
|
+
merged = manual_rules.dup
|
|
57
|
+
auto_rules.each do |auto_rule|
|
|
58
|
+
type = extract_rule_type(auto_rule)
|
|
59
|
+
merged << auto_rule unless type && manual_types.include?(type)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
merged
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Extract the validation type from a rule
|
|
68
|
+
# Rules have format: { required: true, message: "..." }
|
|
69
|
+
# or { minLength: { value: 3 }, message: "..." }
|
|
70
|
+
def extract_rule_type(rule)
|
|
71
|
+
# The first key that isn't :message is the rule type
|
|
72
|
+
(rule.keys - [ :message ]).first
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Map a single validator to frontend rule(s)
|
|
76
|
+
def map_validator(validator, object, attribute)
|
|
77
|
+
case validator
|
|
78
|
+
when ActiveModel::Validations::PresenceValidator
|
|
79
|
+
map_presence(validator, object, attribute)
|
|
80
|
+
when ActiveModel::Validations::LengthValidator
|
|
81
|
+
map_length(validator, object, attribute)
|
|
82
|
+
when ActiveModel::Validations::NumericalityValidator
|
|
83
|
+
map_numericality(validator, object, attribute)
|
|
84
|
+
when ActiveModel::Validations::FormatValidator
|
|
85
|
+
map_format(validator, object, attribute)
|
|
86
|
+
when ActiveModel::Validations::ConfirmationValidator
|
|
87
|
+
map_confirmation(validator, object, attribute)
|
|
88
|
+
when ActiveModel::Validations::ComparisonValidator
|
|
89
|
+
map_comparison(validator, object, attribute)
|
|
90
|
+
when ActiveModel::Validations::InclusionValidator
|
|
91
|
+
map_inclusion(validator, object, attribute)
|
|
92
|
+
when ActiveModel::Validations::ExclusionValidator
|
|
93
|
+
map_exclusion(validator, object, attribute)
|
|
94
|
+
when ActiveModel::Validations::AcceptanceValidator
|
|
95
|
+
map_acceptance(validator, object, attribute)
|
|
96
|
+
when ActiveModel::Validations::AbsenceValidator
|
|
97
|
+
map_absence(validator, object, attribute)
|
|
98
|
+
when defined?(ActiveRecord) && ActiveRecord::Validations::PresenceValidator
|
|
99
|
+
map_presence(validator, object, attribute)
|
|
100
|
+
else
|
|
101
|
+
# Unknown validator type - skip for now
|
|
102
|
+
# Can be extended by adding more cases
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Map presence validation to required rule
|
|
108
|
+
def map_presence(validator, object, attribute)
|
|
109
|
+
[ {
|
|
110
|
+
required: true,
|
|
111
|
+
message: validation_message(object, attribute, :blank, {}, validator.options)
|
|
112
|
+
} ]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Map length validation to minLength/maxLength rules
|
|
116
|
+
def map_length(validator, object, attribute)
|
|
117
|
+
rules = Array.new
|
|
118
|
+
options = validator.options
|
|
119
|
+
|
|
120
|
+
# Exact length
|
|
121
|
+
exact = options[:is]
|
|
122
|
+
if exact
|
|
123
|
+
rules << {
|
|
124
|
+
minLength: { value: exact },
|
|
125
|
+
maxLength: { value: exact },
|
|
126
|
+
message: validation_message(object, attribute, :wrong_length, { count: exact }, options)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return rules
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Minimum length
|
|
133
|
+
min = options[:minimum] || (options[:in]&.min if options[:in].respond_to?(:min))
|
|
134
|
+
if min
|
|
135
|
+
rules << {
|
|
136
|
+
minLength: { value: min },
|
|
137
|
+
message: validation_message(object, attribute, :too_short, { count: min }, options)
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Maximum length
|
|
142
|
+
max = options[:maximum] || options[:is] || (options[:in]&.max if options[:in].respond_to?(:max))
|
|
143
|
+
if max
|
|
144
|
+
rules << {
|
|
145
|
+
maxLength: { value: max },
|
|
146
|
+
message: validation_message(object, attribute, :too_long, { count: max }, options)
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
rules
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Map numericality validation to min/max rules
|
|
154
|
+
def map_numericality(validator, object, attribute)
|
|
155
|
+
rules = Array.new
|
|
156
|
+
options = validator.options
|
|
157
|
+
|
|
158
|
+
# Minimum value (inclusive)
|
|
159
|
+
if options[:greater_than_or_equal_to]
|
|
160
|
+
rules << {
|
|
161
|
+
min: { value: options[:greater_than_or_equal_to] },
|
|
162
|
+
message: validation_message(
|
|
163
|
+
object,
|
|
164
|
+
attribute,
|
|
165
|
+
:greater_than_or_equal_to,
|
|
166
|
+
{ count: options[:greater_than_or_equal_to] },
|
|
167
|
+
options
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Minimum value (exclusive) - add 1 for frontend
|
|
173
|
+
if options[:greater_than]
|
|
174
|
+
min_value = options[:greater_than] + 1
|
|
175
|
+
rules << {
|
|
176
|
+
min: { value: min_value },
|
|
177
|
+
message: validation_message(
|
|
178
|
+
object,
|
|
179
|
+
attribute,
|
|
180
|
+
:greater_than,
|
|
181
|
+
{ count: options[:greater_than] },
|
|
182
|
+
options
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Maximum value (inclusive)
|
|
188
|
+
if options[:less_than_or_equal_to]
|
|
189
|
+
rules << {
|
|
190
|
+
max: { value: options[:less_than_or_equal_to] },
|
|
191
|
+
message: validation_message(
|
|
192
|
+
object,
|
|
193
|
+
attribute,
|
|
194
|
+
:less_than_or_equal_to,
|
|
195
|
+
{ count: options[:less_than_or_equal_to] },
|
|
196
|
+
options
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Maximum value (exclusive) - subtract 1 for frontend
|
|
202
|
+
if options[:less_than]
|
|
203
|
+
max_value = options[:less_than] - 1
|
|
204
|
+
rules << {
|
|
205
|
+
max: { value: max_value },
|
|
206
|
+
message: validation_message(
|
|
207
|
+
object,
|
|
208
|
+
attribute,
|
|
209
|
+
:less_than,
|
|
210
|
+
{ count: options[:less_than] },
|
|
211
|
+
options
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
rules
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Map format validation to pattern/email/url rule
|
|
220
|
+
def map_format(validator, object, attribute)
|
|
221
|
+
options = validator.options
|
|
222
|
+
regex = options[:with]
|
|
223
|
+
return [] unless regex.is_a?(Regexp)
|
|
224
|
+
|
|
225
|
+
regex_source = regex.source
|
|
226
|
+
|
|
227
|
+
# Detect email pattern
|
|
228
|
+
if email_regex?(regex_source) || attribute.to_s.match?(/email/i)
|
|
229
|
+
return [ {
|
|
230
|
+
email: true,
|
|
231
|
+
message: validation_message(object, attribute, :invalid, {}, options)
|
|
232
|
+
} ]
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Detect URL pattern
|
|
236
|
+
if url_regex?(regex_source) || attribute.to_s.match?(/url|website|link/i)
|
|
237
|
+
return [ {
|
|
238
|
+
url: true,
|
|
239
|
+
message: validation_message(object, attribute, :invalid, {}, options)
|
|
240
|
+
} ]
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Generic pattern rule
|
|
244
|
+
# Remove anchors (\A, \z, ^, $) as HTML pattern adds them implicitly
|
|
245
|
+
pattern = regex_source.gsub(/\A(\\A|\^)|(\$|\\z)\z/, "")
|
|
246
|
+
|
|
247
|
+
[ {
|
|
248
|
+
pattern: { value: pattern },
|
|
249
|
+
message: validation_message(object, attribute, :invalid, {}, options)
|
|
250
|
+
} ]
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Map confirmation validation to match rule
|
|
254
|
+
def map_confirmation(validator, object, attribute)
|
|
255
|
+
[ {
|
|
256
|
+
match: { field: "#{attribute}_confirmation" },
|
|
257
|
+
message: validation_message(object, attribute, :confirmation, {}, validator.options)
|
|
258
|
+
} ]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Map comparison validation to comparison rule
|
|
262
|
+
def map_comparison(validator, object, attribute)
|
|
263
|
+
options = validator.options
|
|
264
|
+
rules = Array.new
|
|
265
|
+
|
|
266
|
+
comparison_keys = %i[
|
|
267
|
+
greater_than
|
|
268
|
+
greater_than_or_equal_to
|
|
269
|
+
equal_to
|
|
270
|
+
less_than
|
|
271
|
+
less_than_or_equal_to
|
|
272
|
+
other_than
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
comparison_keys.each do |key|
|
|
276
|
+
next unless options.key?(key)
|
|
277
|
+
|
|
278
|
+
target = options[key]
|
|
279
|
+
comparison = build_comparison_target(target, object)
|
|
280
|
+
next unless comparison
|
|
281
|
+
|
|
282
|
+
message_options = comparison_message_options(target, object, comparison)
|
|
283
|
+
rules << {
|
|
284
|
+
comparison: comparison.merge(operator: key),
|
|
285
|
+
message: validation_message(object, attribute, key, message_options, options)
|
|
286
|
+
}
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
rules
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Map inclusion validation to enum rule
|
|
293
|
+
def map_inclusion(validator, object, attribute)
|
|
294
|
+
options = validator.options
|
|
295
|
+
values = options[:in] || options[:within]
|
|
296
|
+
|
|
297
|
+
return [] unless values
|
|
298
|
+
|
|
299
|
+
[ {
|
|
300
|
+
enum: { values: values.to_a },
|
|
301
|
+
message: validation_message(object, attribute, :inclusion, {}, options)
|
|
302
|
+
} ]
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Map exclusion validation to forbidden values rule
|
|
306
|
+
def map_exclusion(validator, object, attribute)
|
|
307
|
+
options = validator.options
|
|
308
|
+
values = options[:in] || options[:within]
|
|
309
|
+
|
|
310
|
+
return [] unless values
|
|
311
|
+
|
|
312
|
+
[ {
|
|
313
|
+
exclusion: { values: values.to_a },
|
|
314
|
+
message: validation_message(object, attribute, :exclusion, {}, options)
|
|
315
|
+
} ]
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Map acceptance validation to required rule
|
|
319
|
+
def map_acceptance(validator, object, attribute)
|
|
320
|
+
[ {
|
|
321
|
+
required: true,
|
|
322
|
+
message: validation_message(object, attribute, :accepted, {}, validator.options)
|
|
323
|
+
} ]
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Map absence validation to absent rule (field must be blank)
|
|
327
|
+
def map_absence(validator, object, attribute)
|
|
328
|
+
[ {
|
|
329
|
+
absent: true,
|
|
330
|
+
message: validation_message(object, attribute, :present, {}, validator.options)
|
|
331
|
+
} ]
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Check if regex matches email pattern
|
|
335
|
+
def email_regex?(regex_source)
|
|
336
|
+
# Common email regex patterns
|
|
337
|
+
regex_source.match?(/@.*\./) || regex_source.include?("EMAIL")
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Check if regex matches URL pattern
|
|
341
|
+
def url_regex?(regex_source)
|
|
342
|
+
# Common URL regex patterns
|
|
343
|
+
regex_source.match?(/https?|:\/\//) || regex_source.include?("URL")
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Humanize attribute name
|
|
347
|
+
def humanize(attribute)
|
|
348
|
+
attribute.to_s.humanize
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Use Rails' error message generation to honor I18n and custom messages
|
|
352
|
+
def validation_message(object, attribute, type, options, validator_options)
|
|
353
|
+
return fallback_message(attribute, type, options, validator_options) unless object&.respond_to?(:errors)
|
|
354
|
+
return fallback_message(attribute, type, options, validator_options) if anonymous_class?(object)
|
|
355
|
+
|
|
356
|
+
value = object.respond_to?(attribute) ? object.public_send(attribute) : nil
|
|
357
|
+
message = object.errors.generate_message(
|
|
358
|
+
attribute,
|
|
359
|
+
type,
|
|
360
|
+
options.merge(value: value, message: validator_options[:message])
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
if object.errors.respond_to?(:full_message) && object.class.name && !object.class.name.empty?
|
|
364
|
+
object.errors.full_message(attribute, message)
|
|
365
|
+
else
|
|
366
|
+
message
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def anonymous_class?(object)
|
|
371
|
+
class_name = object.class.name
|
|
372
|
+
class_name.nil? || class_name.empty?
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Build comparison target from validator option value
|
|
376
|
+
#
|
|
377
|
+
# @param target [Symbol, String, Proc, Object] The comparison target value
|
|
378
|
+
# @param _object [Object] The model object (unused, Procs are not evaluated)
|
|
379
|
+
# @return [Hash, nil] Hash with :field or :value key, or nil if cannot be used client-side
|
|
380
|
+
#
|
|
381
|
+
# @note Procs are always ignored (return nil) because they cannot be reliably
|
|
382
|
+
# evaluated on the client-side. For field comparisons, use Symbol references:
|
|
383
|
+
# `comparison: { greater_than: :start_date }` instead of Procs.
|
|
384
|
+
def build_comparison_target(target, _object)
|
|
385
|
+
case target
|
|
386
|
+
when Symbol, String
|
|
387
|
+
{ field: target.to_s }
|
|
388
|
+
when Proc
|
|
389
|
+
# Procs cannot be evaluated reliably on client-side
|
|
390
|
+
# Skip this validation rule for frontend
|
|
391
|
+
nil
|
|
392
|
+
else
|
|
393
|
+
{ value: target }
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def comparison_message_options(target, object, comparison)
|
|
398
|
+
if comparison[:field]
|
|
399
|
+
{
|
|
400
|
+
count: humanize(comparison[:field])
|
|
401
|
+
}
|
|
402
|
+
else
|
|
403
|
+
resolved = resolve_comparison_value(target, object)
|
|
404
|
+
{
|
|
405
|
+
count: resolved
|
|
406
|
+
}
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def resolve_comparison_value(target, object)
|
|
411
|
+
return object.public_send(target) if target.is_a?(Symbol) && object&.respond_to?(target)
|
|
412
|
+
return target.call(object) if target.respond_to?(:call)
|
|
413
|
+
|
|
414
|
+
target
|
|
415
|
+
rescue StandardError
|
|
416
|
+
nil
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Fallback for standalone objects or anonymous classes where Rails can't build model-based messages.
|
|
420
|
+
# This keeps validations usable without requiring ActiveRecord models.
|
|
421
|
+
def fallback_message(attribute, type, options, validator_options)
|
|
422
|
+
message = validator_options[:message]
|
|
423
|
+
return message if message.is_a?(String)
|
|
424
|
+
|
|
425
|
+
local_options = options.dup
|
|
426
|
+
if type == :confirmation && !local_options.key?(:attribute)
|
|
427
|
+
local_options[:attribute] = humanize(:"#{attribute}_confirmation")
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
default_text = fallback_message_text(type, local_options)
|
|
431
|
+
return "#{humanize(attribute)} #{default_text}" unless defined?(I18n) && I18n.respond_to?(:t)
|
|
432
|
+
|
|
433
|
+
translated = I18n.t(
|
|
434
|
+
:"errors.messages.#{type}",
|
|
435
|
+
**local_options,
|
|
436
|
+
default: default_text
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
"#{humanize(attribute)} #{translated}"
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def fallback_message_text(type, options)
|
|
443
|
+
count = options[:count]
|
|
444
|
+
case type
|
|
445
|
+
when :blank
|
|
446
|
+
"can't be blank"
|
|
447
|
+
when :too_short
|
|
448
|
+
"is too short (minimum is #{count} characters)"
|
|
449
|
+
when :too_long
|
|
450
|
+
"is too long (maximum is #{count} characters)"
|
|
451
|
+
when :wrong_length
|
|
452
|
+
"is the wrong length (should be #{count} characters)"
|
|
453
|
+
when :greater_than_or_equal_to
|
|
454
|
+
"must be greater than or equal to #{count}"
|
|
455
|
+
when :greater_than
|
|
456
|
+
"must be greater than #{count}"
|
|
457
|
+
when :less_than_or_equal_to
|
|
458
|
+
"must be less than or equal to #{count}"
|
|
459
|
+
when :less_than
|
|
460
|
+
"must be less than #{count}"
|
|
461
|
+
when :equal_to
|
|
462
|
+
"must be equal to #{count}"
|
|
463
|
+
when :other_than
|
|
464
|
+
"must be other than #{count}"
|
|
465
|
+
when :confirmation
|
|
466
|
+
"doesn't match #{options[:attribute]}"
|
|
467
|
+
when :inclusion
|
|
468
|
+
"is not included in the list"
|
|
469
|
+
when :exclusion
|
|
470
|
+
"is reserved"
|
|
471
|
+
when :accepted
|
|
472
|
+
"must be accepted"
|
|
473
|
+
when :present
|
|
474
|
+
"must be blank"
|
|
475
|
+
when :invalid
|
|
476
|
+
"is invalid"
|
|
477
|
+
else
|
|
478
|
+
type.to_s.tr("_", " ")
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
end
|
data/lib/hakumi_components.rb
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "view_component"
|
|
3
4
|
require "hakumi_components/version"
|
|
4
5
|
require "hakumi_components/engine"
|
|
5
6
|
require "hakumi_components/rails"
|
|
7
|
+
require "hakumi_components/documentation"
|
|
6
8
|
|
|
7
|
-
module
|
|
9
|
+
module Hakumi
|
|
8
10
|
class << self
|
|
9
11
|
def configure
|
|
10
12
|
yield configuration
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :coverage do
|
|
4
|
+
desc "Run tests with coverage report (no parallel)"
|
|
5
|
+
task :report do
|
|
6
|
+
puts "🔍 Running tests with coverage (no parallelization)..."
|
|
7
|
+
puts ""
|
|
8
|
+
|
|
9
|
+
# Set environment variables
|
|
10
|
+
ENV["COVERAGE"] = "1"
|
|
11
|
+
ENV["PARALLEL"] = "0"
|
|
12
|
+
|
|
13
|
+
# Clean previous coverage
|
|
14
|
+
sh "rm -rf coverage" rescue nil
|
|
15
|
+
|
|
16
|
+
# Run tests
|
|
17
|
+
Rake::Task["test"].invoke
|
|
18
|
+
|
|
19
|
+
puts ""
|
|
20
|
+
puts "✅ Coverage report generated at: coverage/index.html"
|
|
21
|
+
puts ""
|
|
22
|
+
puts "To view the report:"
|
|
23
|
+
puts " - macOS: open coverage/index.html"
|
|
24
|
+
puts " - Linux: xdg-open coverage/index.html"
|
|
25
|
+
puts ""
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
desc "Run tests with coverage and console output"
|
|
29
|
+
task :console do
|
|
30
|
+
ENV["COVERAGE_CONSOLE"] = "1"
|
|
31
|
+
Rake::Task["coverage:report"].invoke
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Alias for convenience
|
|
36
|
+
desc "Run tests with coverage (alias for coverage:report)"
|
|
37
|
+
task coverage: "coverage:report"
|
|
@@ -65,6 +65,11 @@ module Hakumi
|
|
|
65
65
|
# @return [String, nil] CSS dimension string
|
|
66
66
|
def dimension_to_css: ((Numeric | String | nil) value) -> String?
|
|
67
67
|
|
|
68
|
+
# Cast a value to boolean using ActiveModel::Type::Boolean
|
|
69
|
+
# @param value [untyped] Value to cast
|
|
70
|
+
# @return [Boolean] Casted boolean value
|
|
71
|
+
def cast_boolean: (untyped value) -> bool
|
|
72
|
+
|
|
68
73
|
# Build inline style string from array or hash
|
|
69
74
|
# @param styles [Array<String>, Hash] Styles as array of CSS strings or hash of property => value
|
|
70
75
|
# @return [String, nil] Combined style string
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module Hakumi
|
|
4
4
|
module Checkbox
|
|
5
5
|
class Component < Hakumi::BaseComponent
|
|
6
|
+
include Hakumi::Concerns::FormField
|
|
7
|
+
|
|
6
8
|
@checked: bool
|
|
7
9
|
@disabled: bool
|
|
8
10
|
@indeterminate: bool
|
|
@@ -11,6 +13,10 @@ module Hakumi
|
|
|
11
13
|
@id: String
|
|
12
14
|
@auto_focus: bool
|
|
13
15
|
@label: String?
|
|
16
|
+
@caption: String?
|
|
17
|
+
@standalone: bool
|
|
18
|
+
@required: bool
|
|
19
|
+
@errors: Array[String]
|
|
14
20
|
@wrapper_id: String
|
|
15
21
|
@html_options: hakumi_html_options
|
|
16
22
|
|
|
@@ -23,6 +29,10 @@ module Hakumi
|
|
|
23
29
|
?id: String?,
|
|
24
30
|
?auto_focus: bool,
|
|
25
31
|
?label: String?,
|
|
32
|
+
?caption: String?,
|
|
33
|
+
?standalone: bool,
|
|
34
|
+
?required: bool,
|
|
35
|
+
?errors: Array[String],
|
|
26
36
|
**hakumi_html_options html_options
|
|
27
37
|
) -> void
|
|
28
38
|
|
data/sig/hakumi/form_builder.rbs
CHANGED
|
@@ -16,13 +16,21 @@ module Hakumi
|
|
|
16
16
|
def mentions_field: (Symbol method, **untyped options) -> String
|
|
17
17
|
def slider_field: (Symbol method, **untyped options) -> String
|
|
18
18
|
def rate_field: (Symbol method, **untyped options) -> String
|
|
19
|
+
def autocomplete_field: (Symbol method, untyped? choices, **untyped options) -> String
|
|
20
|
+
def cascader_field: (Symbol method, untyped? choices, **untyped options) -> String
|
|
21
|
+
def checkbox_field: (Symbol method, **untyped options) -> String
|
|
22
|
+
def color_picker_field: (Symbol method, **untyped options) -> String
|
|
23
|
+
def time_picker_field: (Symbol method, **untyped options) -> String
|
|
24
|
+
def transfer_field: (Symbol method, untyped? data_source, **untyped options) -> String
|
|
25
|
+
def upload_field: (Symbol method, **untyped options) -> String
|
|
19
26
|
def submit: (String? value, **untyped options) -> String
|
|
20
27
|
|
|
21
28
|
private
|
|
22
29
|
|
|
23
30
|
# Private helper methods
|
|
31
|
+
def field_configuration: (Symbol method, Hash[Symbol, untyped] options, ?id_suffix: String?) -> Hash[Symbol, untyped]
|
|
24
32
|
def enhance_options_with_introspection!: (Symbol method, Hash[Symbol, untyped] options) -> (Hash[Symbol, untyped] | nil)
|
|
25
|
-
def render_form_field: (Class component_class, Symbol method, **untyped options) -> String
|
|
33
|
+
def render_form_field: (Class component_class, Symbol method, ?config_overrides: Hash[Symbol, untyped], **untyped options) -> String
|
|
26
34
|
def object_value: (Symbol method) -> untyped
|
|
27
35
|
def object_errors: (Symbol method) -> Array[String]
|
|
28
36
|
end
|