phlexi-form 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 +7 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -0
- data/Appraisals +13 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +395 -0
- data/Rakefile +14 -0
- data/config.ru +9 -0
- data/gemfiles/default.gemfile +5 -0
- data/gemfiles/default.gemfile.lock +174 -0
- data/lib/generators/superform/install/USAGE +8 -0
- data/lib/generators/superform/install/install_generator.rb +34 -0
- data/lib/generators/superform/install/templates/application_form.rb +31 -0
- data/lib/phlexi/form/base.rb +234 -0
- data/lib/phlexi/form/components/base.rb +37 -0
- data/lib/phlexi/form/components/checkbox.rb +43 -0
- data/lib/phlexi/form/components/collection_checkboxes.rb +30 -0
- data/lib/phlexi/form/components/collection_radio_buttons.rb +29 -0
- data/lib/phlexi/form/components/concerns/has_options.rb +33 -0
- data/lib/phlexi/form/components/error.rb +21 -0
- data/lib/phlexi/form/components/full_error.rb +21 -0
- data/lib/phlexi/form/components/hint.rb +21 -0
- data/lib/phlexi/form/components/input.rb +78 -0
- data/lib/phlexi/form/components/label.rb +26 -0
- data/lib/phlexi/form/components/radio_button.rb +31 -0
- data/lib/phlexi/form/components/select.rb +57 -0
- data/lib/phlexi/form/components/textarea.rb +34 -0
- data/lib/phlexi/form/components/wrapper.rb +31 -0
- data/lib/phlexi/form/field_options/autofocus.rb +18 -0
- data/lib/phlexi/form/field_options/collection.rb +37 -0
- data/lib/phlexi/form/field_options/disabled.rb +18 -0
- data/lib/phlexi/form/field_options/errors.rb +82 -0
- data/lib/phlexi/form/field_options/hints.rb +22 -0
- data/lib/phlexi/form/field_options/labels.rb +28 -0
- data/lib/phlexi/form/field_options/length.rb +53 -0
- data/lib/phlexi/form/field_options/limit.rb +66 -0
- data/lib/phlexi/form/field_options/min_max.rb +92 -0
- data/lib/phlexi/form/field_options/multiple.rb +63 -0
- data/lib/phlexi/form/field_options/pattern.rb +38 -0
- data/lib/phlexi/form/field_options/placeholder.rb +18 -0
- data/lib/phlexi/form/field_options/readonly.rb +18 -0
- data/lib/phlexi/form/field_options/required.rb +37 -0
- data/lib/phlexi/form/field_options/type.rb +155 -0
- data/lib/phlexi/form/field_options/validators.rb +48 -0
- data/lib/phlexi/form/option_mapper.rb +154 -0
- data/lib/phlexi/form/structure/dom.rb +57 -0
- data/lib/phlexi/form/structure/field_builder.rb +199 -0
- data/lib/phlexi/form/structure/field_collection.rb +45 -0
- data/lib/phlexi/form/structure/namespace.rb +123 -0
- data/lib/phlexi/form/structure/namespace_collection.rb +48 -0
- data/lib/phlexi/form/structure/node.rb +18 -0
- data/lib/phlexi/form/version.rb +7 -0
- data/lib/phlexi/form.rb +28 -0
- data/lib/phlexi-form.rb +3 -0
- data/sig/phlexi/form.rbs +6 -0
- metadata +243 -0
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Phlexi
|
4
|
+
module Form
|
5
|
+
module FieldOptions
|
6
|
+
module MinMax
|
7
|
+
def min(min_value = nil)
|
8
|
+
if min_value.nil?
|
9
|
+
options[:min] = options.fetch(:min) { calculate_min }
|
10
|
+
else
|
11
|
+
options[:min] = min_value
|
12
|
+
self
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def max(max_value = nil)
|
17
|
+
if max_value.nil?
|
18
|
+
options[:max] = options.fetch(:max) { calculate_max }
|
19
|
+
else
|
20
|
+
options[:max] = max_value
|
21
|
+
self
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def step
|
26
|
+
1 if min || max
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def calculate_min
|
32
|
+
if (numericality_validator = find_numericality_validator)
|
33
|
+
get_min_from_validator(numericality_validator)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def calculate_max
|
38
|
+
if (numericality_validator = find_numericality_validator)
|
39
|
+
get_max_from_validator(numericality_validator)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def find_numericality_validator
|
44
|
+
find_validator(:numericality)
|
45
|
+
end
|
46
|
+
|
47
|
+
def get_min_from_validator(validator)
|
48
|
+
options = validator.options
|
49
|
+
min = if options.key?(:greater_than)
|
50
|
+
{value: options[:greater_than], exclusive: true}
|
51
|
+
elsif options.key?(:greater_than_or_equal_to)
|
52
|
+
{value: options[:greater_than_or_equal_to], exclusive: false}
|
53
|
+
end
|
54
|
+
evaluate_and_adjust_min(min)
|
55
|
+
end
|
56
|
+
|
57
|
+
def get_max_from_validator(validator)
|
58
|
+
options = validator.options
|
59
|
+
max = if options.key?(:less_than)
|
60
|
+
{value: options[:less_than], exclusive: true}
|
61
|
+
elsif options.key?(:less_than_or_equal_to)
|
62
|
+
{value: options[:less_than_or_equal_to], exclusive: false}
|
63
|
+
end
|
64
|
+
evaluate_and_adjust_max(max)
|
65
|
+
end
|
66
|
+
|
67
|
+
def evaluate_and_adjust_min(min)
|
68
|
+
return nil unless min
|
69
|
+
|
70
|
+
value = evaluate_numericality_validator_option(min[:value])
|
71
|
+
min[:exclusive] ? value + 1 : value
|
72
|
+
end
|
73
|
+
|
74
|
+
def evaluate_and_adjust_max(max)
|
75
|
+
return nil unless max
|
76
|
+
|
77
|
+
value = evaluate_numericality_validator_option(max[:value])
|
78
|
+
max[:exclusive] ? value - 1 : value
|
79
|
+
end
|
80
|
+
|
81
|
+
def evaluate_numericality_validator_option(option)
|
82
|
+
case option
|
83
|
+
when Proc
|
84
|
+
option.arity.zero? ? option.call : option.call(object)
|
85
|
+
else
|
86
|
+
option
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Phlexi
|
4
|
+
module Form
|
5
|
+
module FieldOptions
|
6
|
+
module Multiple
|
7
|
+
def multiple?
|
8
|
+
options[:multiple] = options.fetch(:multiple) { calculate_multiple_field_value }
|
9
|
+
end
|
10
|
+
|
11
|
+
def multiple!(multiple = true)
|
12
|
+
options[:multiple] = multiple
|
13
|
+
self
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def calculate_multiple_field_value
|
19
|
+
return true if multiple_field_array_attribute?
|
20
|
+
check_multiple_field_from_validators
|
21
|
+
end
|
22
|
+
|
23
|
+
def multiple_field_array_attribute?
|
24
|
+
return false unless object.class.respond_to?(:columns_hash)
|
25
|
+
|
26
|
+
column = object.class.columns_hash[key.to_s]
|
27
|
+
return false unless column
|
28
|
+
|
29
|
+
case object.class.connection.adapter_name.downcase
|
30
|
+
when "postgresql"
|
31
|
+
column.array? || (column.type == :string && column.sql_type.include?("[]"))
|
32
|
+
end # || object.class.attribute_types[key.to_s].is_a?(ActiveRecord::Type::Serialized)
|
33
|
+
rescue
|
34
|
+
# Rails.logger.warn("Error checking multiple field array attribute: #{e.message}")
|
35
|
+
false
|
36
|
+
end
|
37
|
+
|
38
|
+
def check_multiple_field_from_validators
|
39
|
+
inclusion_validator = find_validator(:inclusion)
|
40
|
+
length_validator = find_validator(:length)
|
41
|
+
|
42
|
+
return false unless inclusion_validator || length_validator
|
43
|
+
|
44
|
+
check_multiple_field_inclusion_validator(inclusion_validator) ||
|
45
|
+
check_multiple_field_length_validator(length_validator)
|
46
|
+
end
|
47
|
+
|
48
|
+
def check_multiple_field_inclusion_validator(validator)
|
49
|
+
return false unless validator
|
50
|
+
in_option = validator.options[:in]
|
51
|
+
return false unless in_option.is_a?(Array)
|
52
|
+
|
53
|
+
validator.options[:multiple] == true || (multiple_field_array_attribute? && in_option.size > 1)
|
54
|
+
end
|
55
|
+
|
56
|
+
def check_multiple_field_length_validator(validator)
|
57
|
+
return false unless validator
|
58
|
+
validator.options[:maximum].to_i > 1 if validator.options[:maximum]
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Phlexi
|
4
|
+
module Form
|
5
|
+
module FieldOptions
|
6
|
+
module Pattern
|
7
|
+
def pattern(pattern = nil)
|
8
|
+
if pattern.nil?
|
9
|
+
options[:pattern] = options.fetch(:pattern) { calculate_pattern }
|
10
|
+
else
|
11
|
+
options[:pattern] = pattern
|
12
|
+
self
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def calculate_pattern
|
19
|
+
if (pattern_validator = find_pattern_validator) && (with = pattern_validator.options[:with])
|
20
|
+
evaluate_format_validator_option(with).source
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def find_pattern_validator
|
25
|
+
find_validator(:format)
|
26
|
+
end
|
27
|
+
|
28
|
+
def evaluate_format_validator_option(option)
|
29
|
+
if option.respond_to?(:call)
|
30
|
+
option.call(object)
|
31
|
+
else
|
32
|
+
option
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Phlexi
|
4
|
+
module Form
|
5
|
+
module FieldOptions
|
6
|
+
module Placeholder
|
7
|
+
def placeholder(placeholder = nil)
|
8
|
+
if placeholder.nil?
|
9
|
+
options[:placeholder]
|
10
|
+
else
|
11
|
+
options[:placeholder] = placeholder
|
12
|
+
self
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Phlexi
|
4
|
+
module Form
|
5
|
+
module FieldOptions
|
6
|
+
module Readonly
|
7
|
+
def readonly?
|
8
|
+
options[:readonly] == true
|
9
|
+
end
|
10
|
+
|
11
|
+
def readonly!(readonly = true)
|
12
|
+
options[:readonly] = readonly
|
13
|
+
self
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Phlexi
|
4
|
+
module Form
|
5
|
+
module FieldOptions
|
6
|
+
module Required
|
7
|
+
def required?
|
8
|
+
options[:required] = options.fetch(:required) { calculate_required }
|
9
|
+
end
|
10
|
+
|
11
|
+
def required!(required = true)
|
12
|
+
options[:required] = required
|
13
|
+
self
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def calculate_required
|
19
|
+
if has_validators?
|
20
|
+
required_by_validators?
|
21
|
+
else
|
22
|
+
required_by_default?
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def required_by_validators?
|
27
|
+
(attribute_validators + reflection_validators).any? { |v| v.kind == :presence && valid_validator?(v) }
|
28
|
+
end
|
29
|
+
|
30
|
+
def required_by_default?
|
31
|
+
# TODO: get this from configuration
|
32
|
+
false
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,155 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Phlexi
|
4
|
+
module Form
|
5
|
+
module FieldOptions
|
6
|
+
module Type
|
7
|
+
def db_type
|
8
|
+
@db_type ||= infer_db_type
|
9
|
+
end
|
10
|
+
|
11
|
+
def input_component
|
12
|
+
@input_component ||= infer_input_component
|
13
|
+
end
|
14
|
+
|
15
|
+
def input_type
|
16
|
+
@input_type ||= infer_input_type
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
# this returns the element type
|
22
|
+
# one of :input, :textarea, :select, :botton
|
23
|
+
def infer_input_component
|
24
|
+
return :select unless collection.blank?
|
25
|
+
|
26
|
+
case db_type
|
27
|
+
when :text, :json, :jsonb, :hstore
|
28
|
+
:textarea
|
29
|
+
else
|
30
|
+
:input
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# this only applies when input_component is `:input`
|
35
|
+
# resolves the type attribute of input components
|
36
|
+
def infer_input_type
|
37
|
+
return nil unless input_component == :input
|
38
|
+
|
39
|
+
case db_type
|
40
|
+
when :string
|
41
|
+
infer_string_input_type(key)
|
42
|
+
when :integer, :float, :decimal
|
43
|
+
:number
|
44
|
+
when :date
|
45
|
+
:date
|
46
|
+
when :datetime
|
47
|
+
:datetime
|
48
|
+
when :time
|
49
|
+
:time
|
50
|
+
when :boolean
|
51
|
+
:checkbox
|
52
|
+
else
|
53
|
+
:text
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def infer_db_type
|
58
|
+
if object.class.respond_to?(:columns_hash)
|
59
|
+
# ActiveRecord object
|
60
|
+
column = object.class.columns_hash[key.to_s]
|
61
|
+
return column.type if column
|
62
|
+
end
|
63
|
+
|
64
|
+
if object.class.respond_to?(:attribute_types)
|
65
|
+
# ActiveModel::Attributes
|
66
|
+
custom_type = object.class.attribute_types[key.to_s]
|
67
|
+
return custom_type.type if custom_type
|
68
|
+
end
|
69
|
+
|
70
|
+
# Check if object responds to the key
|
71
|
+
if object.respond_to?(key)
|
72
|
+
# Fallback to inferring type from the value
|
73
|
+
return infer_db_type_from_value(object.send(key))
|
74
|
+
end
|
75
|
+
|
76
|
+
# Default to string if we can't determine the type
|
77
|
+
:string
|
78
|
+
end
|
79
|
+
|
80
|
+
def infer_db_type_from_value(value)
|
81
|
+
case value
|
82
|
+
when Integer
|
83
|
+
:integer
|
84
|
+
when Float, BigDecimal
|
85
|
+
:float
|
86
|
+
when TrueClass, FalseClass
|
87
|
+
:boolean
|
88
|
+
when Date
|
89
|
+
:date
|
90
|
+
when Time, DateTime
|
91
|
+
:datetime
|
92
|
+
else
|
93
|
+
:string
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def infer_string_input_type(key)
|
98
|
+
key = key.to_s.downcase
|
99
|
+
|
100
|
+
return :password if is_password_field?
|
101
|
+
|
102
|
+
custom_type = custom_string_input_type(key)
|
103
|
+
return custom_type if custom_type
|
104
|
+
|
105
|
+
if has_validators?
|
106
|
+
infer_string_input_type_from_validations
|
107
|
+
else
|
108
|
+
:text
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def custom_string_input_type(key)
|
113
|
+
custom_mappings = {
|
114
|
+
/url$|^link|^site/ => :url,
|
115
|
+
/^email/ => :email,
|
116
|
+
/^search/ => :search,
|
117
|
+
/phone|tel(ephone)?/ => :tel,
|
118
|
+
/^time/ => :time,
|
119
|
+
/^date/ => :date,
|
120
|
+
/^number|_count$|_amount$/ => :number,
|
121
|
+
/^color/ => :color
|
122
|
+
}
|
123
|
+
|
124
|
+
custom_mappings.each do |pattern, type|
|
125
|
+
return type if key.match?(pattern)
|
126
|
+
end
|
127
|
+
|
128
|
+
nil
|
129
|
+
end
|
130
|
+
|
131
|
+
def infer_string_input_type_from_validations
|
132
|
+
if attribute_validators.find { |v| v.kind == :numericality }
|
133
|
+
:number
|
134
|
+
elsif attribute_validators.find { |v| v.kind == :format && v.options[:with] == URI::MailTo::EMAIL_REGEXP }
|
135
|
+
:email
|
136
|
+
else
|
137
|
+
:text
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def is_password_field?
|
142
|
+
key = self.key.to_s.downcase
|
143
|
+
|
144
|
+
exact_matches = ["password"]
|
145
|
+
prefixes = ["encrypted_"]
|
146
|
+
suffixes = ["_password", "_digest", "_hash"]
|
147
|
+
|
148
|
+
exact_matches.include?(key) ||
|
149
|
+
prefixes.any? { |prefix| key.start_with?(prefix) } ||
|
150
|
+
suffixes.any? { |suffix| key.end_with?(suffix) }
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Phlexi
|
4
|
+
module Form
|
5
|
+
module FieldOptions
|
6
|
+
module Validators
|
7
|
+
private
|
8
|
+
|
9
|
+
def has_validators?
|
10
|
+
@has_validators ||= object.class.respond_to?(:validators_on)
|
11
|
+
end
|
12
|
+
|
13
|
+
def attribute_validators
|
14
|
+
object.class.validators_on(key)
|
15
|
+
end
|
16
|
+
|
17
|
+
def reflection_validators
|
18
|
+
reflection ? object.class.validators_on(reflection.name) : []
|
19
|
+
end
|
20
|
+
|
21
|
+
def valid_validator?(validator)
|
22
|
+
!conditional_validators?(validator) && action_validator_match?(validator)
|
23
|
+
end
|
24
|
+
|
25
|
+
def conditional_validators?(validator)
|
26
|
+
validator.options.include?(:if) || validator.options.include?(:unless)
|
27
|
+
end
|
28
|
+
|
29
|
+
def action_validator_match?(validator)
|
30
|
+
return true unless validator.options.include?(:on)
|
31
|
+
|
32
|
+
case validator.options[:on]
|
33
|
+
when :save
|
34
|
+
true
|
35
|
+
when :create
|
36
|
+
!object.persisted?
|
37
|
+
when :update
|
38
|
+
object.persisted?
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def find_validator(kind)
|
43
|
+
attribute_validators.find { |v| v.kind == kind && valid_validator?(v) } if has_validators?
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,154 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Phlexi
|
4
|
+
module Form
|
5
|
+
# OptionMapper is responsible for converting a collection of objects into a hash of options
|
6
|
+
# suitable for form controls, such as `select > options`.
|
7
|
+
# Both values and labels are converted to strings.
|
8
|
+
#
|
9
|
+
# @example Basic usage
|
10
|
+
# collection = [["First", 1], ["Second", 2]]
|
11
|
+
# mapper = OptionMapper.new(collection)
|
12
|
+
# mapper.each { |value, label| puts "#{value}: #{label}" }
|
13
|
+
#
|
14
|
+
# @example Using with ActiveRecord objects
|
15
|
+
# users = User.all
|
16
|
+
# mapper = OptionMapper.new(users)
|
17
|
+
# mapper.each { |id, name| puts "#{id}: #{name}" }
|
18
|
+
#
|
19
|
+
# @example Array access with different value types
|
20
|
+
# mapper = OptionMapper.new([["Integer", 1], ["String", "2"], ["Symbol", :three]])
|
21
|
+
# puts mapper["1"] # Output: "Integer"
|
22
|
+
# puts mapper["2"] # Output: "String"
|
23
|
+
# puts mapper["three"] # Output: "Symbol"
|
24
|
+
#
|
25
|
+
# @note This class is thread-safe as it doesn't maintain mutable state.
|
26
|
+
class OptionMapper
|
27
|
+
include Enumerable
|
28
|
+
|
29
|
+
# Initializes a new OptionMapper instance.
|
30
|
+
#
|
31
|
+
# @param collection [#call, #to_a] The collection to be mapped.
|
32
|
+
# @param label_method [Symbol, nil] The method to call on each object to get the label.
|
33
|
+
# @param value_method [Symbol, nil] The method to call on each object to get the value.
|
34
|
+
def initialize(collection, label_method: nil, value_method: nil)
|
35
|
+
@raw_collection = collection
|
36
|
+
@label_method = label_method
|
37
|
+
@value_method = value_method
|
38
|
+
end
|
39
|
+
|
40
|
+
# Iterates over the collection, yielding value-label pairs.
|
41
|
+
#
|
42
|
+
# @yieldparam value [String] The string value for the current item.
|
43
|
+
# @yieldparam label [String] The string label for the current item.
|
44
|
+
# @return [Enumerator] If no block is given.
|
45
|
+
def each(&)
|
46
|
+
collection.each(&)
|
47
|
+
end
|
48
|
+
|
49
|
+
# @return [Array<String>] An array of all labels in the collection.
|
50
|
+
def labels
|
51
|
+
collection.values
|
52
|
+
end
|
53
|
+
|
54
|
+
# @return [Array<String>] An array of all values in the collection.
|
55
|
+
def values
|
56
|
+
collection.keys
|
57
|
+
end
|
58
|
+
|
59
|
+
# Retrieves the label for a given value.
|
60
|
+
#
|
61
|
+
# @param value [#to_s] The value to look up.
|
62
|
+
# @return [String, nil] The label corresponding to the value, or nil if not found.
|
63
|
+
def [](value)
|
64
|
+
collection[value.to_s]
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
# @return [Hash<String, String>] The materialized collection as a hash of string value => string label.
|
70
|
+
def collection
|
71
|
+
@collection ||= materialize_collection(@raw_collection)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Converts the raw collection into a materialized hash.
|
75
|
+
#
|
76
|
+
# @param collection [#call, #to_a] The collection to be materialized.
|
77
|
+
# @return [Hash<String, String>] The materialized collection as a hash of string value => string label.
|
78
|
+
# @raise [ArgumentError] If the collection cannot be materialized into an enumerable.
|
79
|
+
def materialize_collection(collection)
|
80
|
+
case collection
|
81
|
+
in Hash => hash
|
82
|
+
hash.transform_keys(&:to_s).transform_values(&:to_s)
|
83
|
+
in Array => arr
|
84
|
+
array_to_hash(arr)
|
85
|
+
in Range => range
|
86
|
+
range_to_hash(range)
|
87
|
+
in Proc => proc
|
88
|
+
materialize_collection(proc.call)
|
89
|
+
in Symbol
|
90
|
+
raise ArgumentError, "Symbol collections are not supported in this context"
|
91
|
+
in Set => set
|
92
|
+
array_to_hash(set.to_a)
|
93
|
+
else
|
94
|
+
array_to_hash(Array(collection))
|
95
|
+
end
|
96
|
+
rescue ArgumentError => e
|
97
|
+
# Rails.logger.warn("Unhandled inclusion collection type: #{e}")
|
98
|
+
{}
|
99
|
+
end
|
100
|
+
|
101
|
+
# Converts an array to a hash using detected or specified methods.
|
102
|
+
#
|
103
|
+
# @param array [Array] The array to convert.
|
104
|
+
# @return [Hash<String, String>] The resulting hash of string value => string label.
|
105
|
+
def array_to_hash(array)
|
106
|
+
sample = array.first || array.last
|
107
|
+
methods = detect_methods_for_sample(sample)
|
108
|
+
|
109
|
+
array.each_with_object({}) do |item, hash|
|
110
|
+
value = item.public_send(methods[:value]).to_s
|
111
|
+
label = item.public_send(methods[:label]).to_s
|
112
|
+
hash[value] = label
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# Converts a range to a hash.
|
117
|
+
#
|
118
|
+
# @param range [Range] The range to convert.
|
119
|
+
# @return [Hash<String, String>] The range converted to a hash of string value => string label.
|
120
|
+
# @raise [ArgumentError] If the range is unbounded.
|
121
|
+
def range_to_hash(range)
|
122
|
+
raise ArgumentError, "Cannot safely materialize an unbounded range" if range.begin.nil? || range.end.nil?
|
123
|
+
|
124
|
+
range.each_with_object({}) { |value, hash| hash[value.to_s] = value.to_s }
|
125
|
+
end
|
126
|
+
|
127
|
+
# Detects suitable methods for label and value from a sample object.
|
128
|
+
#
|
129
|
+
# @param sample [Object] A sample object from the collection.
|
130
|
+
# @return [Hash{Symbol => Symbol}] A hash containing :label and :value keys with corresponding method names.
|
131
|
+
def detect_methods_for_sample(sample)
|
132
|
+
case sample
|
133
|
+
when Array
|
134
|
+
{value: :last, label: :first}
|
135
|
+
else
|
136
|
+
{
|
137
|
+
value: @value_method || collection_value_methods.find { |m| sample.respond_to?(m) },
|
138
|
+
label: @label_method || collection_label_methods.find { |m| sample.respond_to?(m) }
|
139
|
+
}
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
# @return [Array<Symbol>] An array of method names to try for collection values.
|
144
|
+
def collection_value_methods
|
145
|
+
@collection_value_methods ||= %i[to_param id to_s].freeze
|
146
|
+
end
|
147
|
+
|
148
|
+
# @return [Array<Symbol>] An array of method names to try for collection labels.
|
149
|
+
def collection_label_methods
|
150
|
+
@collection_label_methods ||= %i[to_label name title to_s].freeze
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Phlexi
|
4
|
+
module Form
|
5
|
+
module Structure
|
6
|
+
# Generates DOM IDs, names, etc. for a Field, Namespace, or Node based on
|
7
|
+
# norms that were established by Rails. These can be used outsidef or Rails in
|
8
|
+
# other Ruby web frameworks since it has now dependencies on Rails.
|
9
|
+
class DOM
|
10
|
+
def initialize(field:)
|
11
|
+
@field = field
|
12
|
+
end
|
13
|
+
|
14
|
+
# Converts the value of the field to a String, which is required to work
|
15
|
+
# with Phlex. Assumes that `Object#to_s` emits a format suitable for the web form.
|
16
|
+
def value
|
17
|
+
@field.value.to_s
|
18
|
+
end
|
19
|
+
|
20
|
+
# Walks from the current node to the parent node, grabs the names, and seperates
|
21
|
+
# them with a `_` for a DOM ID. One limitation of this approach is if multiple forms
|
22
|
+
# exist on the same page, the ID may be duplicate.
|
23
|
+
def id
|
24
|
+
lineage.map(&:key).join("_")
|
25
|
+
end
|
26
|
+
|
27
|
+
# The `name` attribute of a node, which is influenced by Rails (not sure where Rails got
|
28
|
+
# it from). All node names, except the parent node, are wrapped in a `[]` and collections
|
29
|
+
# are left empty. For example, `user[addresses][][street]` would be created for a form with
|
30
|
+
# data shaped like `{user: {addresses: [{street: "Sesame Street"}]}}`.
|
31
|
+
def name
|
32
|
+
root, *names = keys
|
33
|
+
names.map { |name| "[#{name}]" }.unshift(root).join
|
34
|
+
end
|
35
|
+
|
36
|
+
# Emit the id, name, and value in an HTML tag-ish that doesnt have an element.
|
37
|
+
def inspect
|
38
|
+
"<id=#{id.inspect} name=#{name.inspect} value=#{value.inspect}/>"
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def keys
|
44
|
+
lineage.map do |node|
|
45
|
+
# If the parent of a field is a field, the name should be nil.
|
46
|
+
node.key unless node.parent.is_a? FieldBuilder
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# One-liner way of walking from the current node all the way up to the parent.
|
51
|
+
def lineage
|
52
|
+
Enumerator.produce(@field, &:parent).take_while(&:itself).reverse
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|