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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.ruby-version +1 -0
  4. data/Appraisals +13 -0
  5. data/CHANGELOG.md +5 -0
  6. data/CODE_OF_CONDUCT.md +84 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +395 -0
  9. data/Rakefile +14 -0
  10. data/config.ru +9 -0
  11. data/gemfiles/default.gemfile +5 -0
  12. data/gemfiles/default.gemfile.lock +174 -0
  13. data/lib/generators/superform/install/USAGE +8 -0
  14. data/lib/generators/superform/install/install_generator.rb +34 -0
  15. data/lib/generators/superform/install/templates/application_form.rb +31 -0
  16. data/lib/phlexi/form/base.rb +234 -0
  17. data/lib/phlexi/form/components/base.rb +37 -0
  18. data/lib/phlexi/form/components/checkbox.rb +43 -0
  19. data/lib/phlexi/form/components/collection_checkboxes.rb +30 -0
  20. data/lib/phlexi/form/components/collection_radio_buttons.rb +29 -0
  21. data/lib/phlexi/form/components/concerns/has_options.rb +33 -0
  22. data/lib/phlexi/form/components/error.rb +21 -0
  23. data/lib/phlexi/form/components/full_error.rb +21 -0
  24. data/lib/phlexi/form/components/hint.rb +21 -0
  25. data/lib/phlexi/form/components/input.rb +78 -0
  26. data/lib/phlexi/form/components/label.rb +26 -0
  27. data/lib/phlexi/form/components/radio_button.rb +31 -0
  28. data/lib/phlexi/form/components/select.rb +57 -0
  29. data/lib/phlexi/form/components/textarea.rb +34 -0
  30. data/lib/phlexi/form/components/wrapper.rb +31 -0
  31. data/lib/phlexi/form/field_options/autofocus.rb +18 -0
  32. data/lib/phlexi/form/field_options/collection.rb +37 -0
  33. data/lib/phlexi/form/field_options/disabled.rb +18 -0
  34. data/lib/phlexi/form/field_options/errors.rb +82 -0
  35. data/lib/phlexi/form/field_options/hints.rb +22 -0
  36. data/lib/phlexi/form/field_options/labels.rb +28 -0
  37. data/lib/phlexi/form/field_options/length.rb +53 -0
  38. data/lib/phlexi/form/field_options/limit.rb +66 -0
  39. data/lib/phlexi/form/field_options/min_max.rb +92 -0
  40. data/lib/phlexi/form/field_options/multiple.rb +63 -0
  41. data/lib/phlexi/form/field_options/pattern.rb +38 -0
  42. data/lib/phlexi/form/field_options/placeholder.rb +18 -0
  43. data/lib/phlexi/form/field_options/readonly.rb +18 -0
  44. data/lib/phlexi/form/field_options/required.rb +37 -0
  45. data/lib/phlexi/form/field_options/type.rb +155 -0
  46. data/lib/phlexi/form/field_options/validators.rb +48 -0
  47. data/lib/phlexi/form/option_mapper.rb +154 -0
  48. data/lib/phlexi/form/structure/dom.rb +57 -0
  49. data/lib/phlexi/form/structure/field_builder.rb +199 -0
  50. data/lib/phlexi/form/structure/field_collection.rb +45 -0
  51. data/lib/phlexi/form/structure/namespace.rb +123 -0
  52. data/lib/phlexi/form/structure/namespace_collection.rb +48 -0
  53. data/lib/phlexi/form/structure/node.rb +18 -0
  54. data/lib/phlexi/form/version.rb +7 -0
  55. data/lib/phlexi/form.rb +28 -0
  56. data/lib/phlexi-form.rb +3 -0
  57. data/sig/phlexi/form.rbs +6 -0
  58. 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