phlexi-form 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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