phlexi-form 0.2.0 → 0.3.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/Appraisals +4 -9
  3. data/README.md +117 -316
  4. data/TODO +4 -0
  5. data/config.ru +0 -3
  6. data/gemfiles/default.gemfile.lock +22 -2
  7. data/gemfiles/rails_7.gemfile +8 -0
  8. data/gemfiles/rails_7.gemfile.lock +282 -0
  9. data/lib/phlexi/form/base.rb +65 -56
  10. data/lib/phlexi/form/components/base.rb +14 -8
  11. data/lib/phlexi/form/components/checkbox.rb +5 -0
  12. data/lib/phlexi/form/components/collection_checkboxes.rb +28 -14
  13. data/lib/phlexi/form/components/collection_radio_buttons.rb +19 -13
  14. data/lib/phlexi/form/components/concerns/extracts_input.rb +53 -0
  15. data/lib/phlexi/form/components/concerns/handles_array_input.rb +21 -0
  16. data/lib/phlexi/form/components/concerns/handles_input.rb +23 -0
  17. data/lib/phlexi/form/components/concerns/has_options.rb +6 -2
  18. data/lib/phlexi/form/components/concerns/submits_form.rb +47 -0
  19. data/lib/phlexi/form/components/error.rb +1 -1
  20. data/lib/phlexi/form/components/file_input.rb +33 -0
  21. data/lib/phlexi/form/components/hint.rb +1 -1
  22. data/lib/phlexi/form/components/input.rb +38 -36
  23. data/lib/phlexi/form/components/input_array.rb +45 -0
  24. data/lib/phlexi/form/components/label.rb +2 -1
  25. data/lib/phlexi/form/components/radio_button.rb +11 -1
  26. data/lib/phlexi/form/components/select.rb +21 -8
  27. data/lib/phlexi/form/components/submit_button.rb +41 -0
  28. data/lib/phlexi/form/components/textarea.rb +2 -3
  29. data/lib/phlexi/form/field_options/associations.rb +21 -0
  30. data/lib/phlexi/form/field_options/autofocus.rb +1 -1
  31. data/lib/phlexi/form/field_options/collection.rb +26 -9
  32. data/lib/phlexi/form/field_options/errors.rb +17 -3
  33. data/lib/phlexi/form/field_options/hints.rb +5 -1
  34. data/lib/phlexi/form/field_options/{type.rb → inferred_types.rb} +21 -17
  35. data/lib/phlexi/form/field_options/multiple.rb +2 -0
  36. data/lib/phlexi/form/field_options/required.rb +1 -1
  37. data/lib/phlexi/form/field_options/themes.rb +207 -0
  38. data/lib/phlexi/form/field_options/validators.rb +2 -2
  39. data/lib/phlexi/form/option_mapper.rb +2 -2
  40. data/lib/phlexi/form/structure/dom.rb +21 -16
  41. data/lib/phlexi/form/structure/field_builder.rb +165 -121
  42. data/lib/phlexi/form/structure/field_collection.rb +20 -6
  43. data/lib/phlexi/form/structure/namespace.rb +48 -31
  44. data/lib/phlexi/form/structure/namespace_collection.rb +20 -20
  45. data/lib/phlexi/form/structure/node.rb +13 -3
  46. data/lib/phlexi/form/version.rb +1 -1
  47. data/lib/phlexi/form.rb +4 -1
  48. metadata +32 -7
  49. data/CODE_OF_CONDUCT.md +0 -84
@@ -5,13 +5,22 @@ require "phlex"
5
5
  module Phlexi
6
6
  module Form
7
7
  module Structure
8
+ # FieldBuilder class is responsible for building form fields with various options and components.
9
+ #
10
+ # @attr_reader [Structure::DOM] dom The DOM structure for the field.
11
+ # @attr_reader [Hash] options Options for the field.
12
+ # @attr_reader [Object] object The object associated with the field.
13
+ # @attr_reader [Hash] attributes Attributes for the field.
14
+ # @attr_accessor [Object] value The value of the field.
8
15
  class FieldBuilder < Node
9
16
  include Phlex::Helpers
17
+ include FieldOptions::Associations
18
+ include FieldOptions::Themes
10
19
  include FieldOptions::Validators
11
20
  include FieldOptions::Labels
12
21
  include FieldOptions::Hints
13
22
  include FieldOptions::Errors
14
- include FieldOptions::Type
23
+ include FieldOptions::InferredTypes
15
24
  include FieldOptions::Collection
16
25
  include FieldOptions::Placeholder
17
26
  include FieldOptions::Required
@@ -24,175 +33,210 @@ module Phlexi
24
33
  include FieldOptions::Multiple
25
34
  include FieldOptions::Limit
26
35
 
27
- attr_reader :dom, :options, :object, :attributes
28
- attr_accessor :value
29
- alias_method :serialize, :value
30
- alias_method :assign, :value=
36
+ attr_reader :dom, :options, :object, :input_attributes, :value
31
37
 
32
- def initialize(key, parent:, object: nil, value: :__i_form_builder_nil_value_i__, attributes: {}, **options)
33
- key = :"#{key}"
38
+ # Initializes a new FieldBuilder instance.
39
+ #
40
+ # @param key [Symbol, String] The key for the field.
41
+ # @param parent [Structure::Namespace] The parent object.
42
+ # @param object [Object, nil] The associated object.
43
+ # @param value [Object] The initial value for the field.
44
+ # @param input_attributes [Hash] Default attributes to apply to input fields.
45
+ # @param options [Hash] Additional options for the field.
46
+ def initialize(key, parent:, object: nil, value: NIL_VALUE, input_attributes: {}, **options)
34
47
  super(key, parent: parent)
35
48
 
36
49
  @object = object
37
- @value = if value != :__i_form_builder_nil_value_i__
38
- value
39
- else
40
- object.respond_to?(key) ? object.send(key) : nil
41
- end
42
- @attributes = attributes
50
+ @value = determine_initial_value(value)
51
+ @input_attributes = input_attributes
43
52
  @options = options
44
53
  @dom = Structure::DOM.new(field: self)
45
54
  end
46
55
 
47
- def label_tag(**attributes)
48
- attributes = self.attributes.deep_merge(attributes)
49
- label_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :label)
50
- Components::Label.new(self, class: label_class, **attributes)
56
+ # Creates a label tag for the field.
57
+ #
58
+ # @param attributes [Hash] Additional attributes for the label.
59
+ # @return [Components::Label] The label component.
60
+ def label_tag(**, &)
61
+ create_component(Components::Label, :label, **, &)
51
62
  end
52
63
 
53
- def input_tag(**attributes)
54
- attributes = self.attributes.deep_merge(attributes)
55
- input_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :input)
56
- Components::Input.new(self, class: input_class, **attributes)
64
+ # Creates an input tag for the field.
65
+ #
66
+ # @param attributes [Hash] Additional attributes for the input.
67
+ # @return [Components::Input] The input component.
68
+ def input_tag(**, &)
69
+ create_component(Components::Input, :input, **, &)
57
70
  end
58
71
 
59
- def checkbox_tag(**attributes)
60
- attributes = self.attributes.deep_merge(attributes)
61
- checkbox_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :checkbox)
62
- Components::Checkbox.new(self, class: checkbox_class, **attributes)
72
+ def file_input_tag(**, &)
73
+ create_component(Components::FileInput, :file, **, &)
63
74
  end
64
75
 
65
- def collection_checkboxes_tag(**attributes, &)
66
- attributes = self.attributes.deep_merge(attributes)
67
- collection_checkboxes_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :collection_checkboxes)
68
- Components::CollectionCheckboxes.new(self, class: collection_checkboxes_class, **attributes, &)
76
+ # Creates a checkbox tag for the field.
77
+ #
78
+ # @param attributes [Hash] Additional attributes for the checkbox.
79
+ # @return [Components::Checkbox] The checkbox component.
80
+ def checkbox_tag(**, &)
81
+ create_component(Components::Checkbox, :checkbox, **, &)
69
82
  end
70
83
 
71
- def radio_button_tag(**attributes)
72
- attributes = self.attributes.deep_merge(attributes)
73
- radio_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :radio)
74
- Components::RadioButton.new(self, class: radio_class, **attributes)
84
+ # Creates collection checkboxes for the field.
85
+ #
86
+ # @param attributes [Hash] Additional attributes for the collection checkboxes.
87
+ # @yield [block] The block to be executed for each checkbox.
88
+ # @return [Components::CollectionCheckboxes] The collection checkboxes component.
89
+ def collection_checkboxes_tag(**, &)
90
+ create_component(Components::CollectionCheckboxes, :collection_checkboxes, **, &)
75
91
  end
76
92
 
77
- def collection_radio_buttons_tag(**attributes, &)
78
- attributes = self.attributes.deep_merge(attributes)
79
- collection_radio_buttons_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :collection_radio_buttons)
80
- Components::CollectionRadioButtons.new(self, class: collection_radio_buttons_class, **attributes, &)
93
+ # Creates a radio button tag for the field.
94
+ #
95
+ # @param attributes [Hash] Additional attributes for the radio button.
96
+ # @return [Components::RadioButton] The radio button component.
97
+ def radio_button_tag(**, &)
98
+ create_component(Components::RadioButton, :radio, **, &)
81
99
  end
82
100
 
83
- def textarea_tag(**attributes)
84
- attributes = self.attributes.deep_merge(attributes)
85
- textarea_class = attributes.delete(:class) || themed_input(attributes.delete(:theme) || :textarea)
86
- Components::Textarea.new(self, class: textarea_class, **attributes)
101
+ # Creates collection radio buttons for the field.
102
+ #
103
+ # @param attributes [Hash] Additional attributes for the collection radio buttons.
104
+ # @yield [block] The block to be executed for each radio button.
105
+ # @return [Components::CollectionRadioButtons] The collection radio buttons component.
106
+ def collection_radio_buttons_tag(**, &)
107
+ create_component(Components::CollectionRadioButtons, :collection_radio_buttons, **, &)
87
108
  end
88
109
 
89
- def select_tag(**attributes)
90
- attributes = self.attributes.deep_merge(attributes)
91
- select_class = attributes.delete(:class) || themed_input(attributes.delete(:theme) || :select)
92
- Components::Select.new(self, class: select_class, **attributes)
110
+ # Creates a textarea tag for the field.
111
+ #
112
+ # @param attributes [Hash] Additional attributes for the textarea.
113
+ # @return [Components::Textarea] The textarea component.
114
+ def textarea_tag(**, &)
115
+ create_component(Components::Textarea, :textarea, **, &)
93
116
  end
94
117
 
95
- def hint_tag(**attributes)
96
- attributes = self.attributes.deep_merge(attributes)
97
- hint_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :hint)
98
- Components::Hint.new(self, class: hint_class, **attributes)
118
+ # Creates a select tag for the field.
119
+ #
120
+ # @param attributes [Hash] Additional attributes for the select.
121
+ # @return [Components::Select] The select component.
122
+ def select_tag(**, &)
123
+ create_component(Components::Select, :select, **, &)
99
124
  end
100
125
 
101
- def error_tag(**attributes)
102
- attributes = self.attributes.deep_merge(attributes)
103
- error_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :error)
104
- Components::Error.new(self, class: error_class, **attributes)
126
+ def input_array_tag(**, &)
127
+ create_component(Components::InputArray, :array, **, &)
105
128
  end
106
129
 
107
- def full_error_tag(**attributes)
108
- attributes = self.attributes.deep_merge(attributes)
109
- error_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :error)
110
- Components::FullError.new(self, class: error_class, **attributes)
130
+ # Creates a hint tag for the field.
131
+ #
132
+ # @param attributes [Hash] Additional attributes for the hint.
133
+ # @return [Components::Hint] The hint component.
134
+ def hint_tag(**, &)
135
+ create_component(Components::Hint, :hint, **, &)
111
136
  end
112
137
 
113
- def wrapped(inner: {}, **attributes, &)
114
- attributes = self.attributes.deep_merge(attributes)
115
- wrapper_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :wrapper)
116
- inner[:class] = inner.delete(:class) || themed(inner.delete(:theme) || :inner_wrapper)
117
- Components::Wrapper.new(self, class: wrapper_class, inner:, **attributes, &)
138
+ # Creates an error tag for the field.
139
+ #
140
+ # @param attributes [Hash] Additional attributes for the error.
141
+ # @return [Components::Error] The error component.
142
+ def error_tag(**, &)
143
+ create_component(Components::Error, :error, **, &)
118
144
  end
119
145
 
120
- # Wraps a field that's an array of values with a bunch of fields
146
+ # Creates a full error tag for the field.
121
147
  #
122
- # @example Usage
148
+ # @param attributes [Hash] Additional attributes for the full error.
149
+ # @return [Components::FullError] The full error component.
150
+ def full_error_tag(**, &)
151
+ create_component(Components::FullError, :full_error, **, &)
152
+ end
153
+
154
+ # Wraps the field with additional markup.
123
155
  #
124
- # ```ruby
125
- # Phlexi::Form.new User.new do
126
- # render field(:email).input_tag
127
- # render field(:name).input_tag
128
- # field(:roles).multi([["Admin", "admin"], ["Editor", "editor"]]) do |a|
129
- # render a.label_tag
130
- # render a.input_tag # => name="user[roles][]"
131
- # end
132
- # end
133
- # ```
134
- # The object within the block is a `FieldCollection` object
135
- def multi(range = nil, &)
136
- range ||= Array(collection)
137
- FieldCollection.new(field: self, range:, &)
156
+ # @param inner [Hash] Attributes for the inner wrapper.
157
+ # @param attributes [Hash] Additional attributes for the wrapper.
158
+ # @yield [block] The block to be executed within the wrapper.
159
+ # @return [Components::Wrapper] The wrapper component.
160
+ def wrapped(inner: {}, **attributes, &)
161
+ attributes = apply_component_theme(attributes, :wrapper)
162
+ inner = apply_component_theme(inner, :inner_wrapper)
163
+ Components::Wrapper.new(self, inner: inner, **attributes, &)
138
164
  end
139
165
 
140
- private
166
+ # Creates a repeated field collection.
167
+ #
168
+ # @param range [Integer, #to_a] The range of keys for each field.
169
+ # If an integer (e.g. 6) is passed, it is converted to a range = 1..6
170
+ # @yield [block] The block to be executed for each item in the collection.
171
+ # @return [FieldCollection] The field collection.
172
+ def repeated(range = nil, &)
173
+ FieldCollection.new(field: self, range: range, &)
174
+ end
141
175
 
142
- def themed(component)
143
- tokens(resolve_theme(component), resolve_validity_theme(component)).presence if component
176
+ # Creates a submit button
177
+ #
178
+ # @param attributes [Hash] Additional attributes for the submit.
179
+ # @return [Components::SubmitButton] The submit button component.
180
+ def submit_button_tag(**, &)
181
+ create_component(Components::SubmitButton, :submit_button, **, &)
144
182
  end
145
183
 
146
- def themed_input(input_component)
147
- themed(input_component) || themed(:input) if input_component
184
+ def extract_input(params)
185
+ raise "field##{dom.name} did not define an input component" unless @field_input_extractor
186
+
187
+ @field_input_extractor.extract_input(params)
148
188
  end
149
189
 
150
- def resolve_theme(property)
151
- options[property] || theme[property]
190
+ protected
191
+
192
+ def create_component(component_class, theme_key, **attributes, &)
193
+ attributes = mix(input_attributes, attributes) if component_class.include?(Phlexi::Form::Components::Concerns::HandlesInput)
194
+ component = component_class.new(self, **apply_component_theme(attributes, theme_key), &)
195
+ if component_class.include?(Components::Concerns::ExtractsInput)
196
+ raise "input component already defined: #{@field_input_extractor.inspect}" if @field_input_extractor
197
+
198
+ @field_input_extractor = component
199
+ end
200
+
201
+ component
152
202
  end
153
203
 
154
- def resolve_validity_theme(property)
155
- validity_property = if has_errors?
156
- # Apply invalid class if the object has errors
157
- :"invalid_#{property}"
158
- elsif (object.respond_to?(:persisted?) && object.persisted?) && (object.respond_to?(:errors) && !object.errors.empty?)
159
- # The object is persisted, has been validated, and there are errors (not empty), but this field has no errors
160
- # Apply valid class
161
- :"valid_#{property}"
204
+ def apply_component_theme(attributes, theme_key)
205
+ theme_key = attributes.delete(:theme) || theme_key
206
+ if attributes.key?(:class!)
207
+ attributes
162
208
  else
163
- :"neutral_#{property}"
209
+ mix({class: themed(theme_key)}, attributes)
164
210
  end
165
-
166
- resolve_theme(validity_property)
167
- end
168
-
169
- def theme
170
- @theme ||= {
171
- # # label themes
172
- # label: "md:w-1/6 mt-2 block mb-2 text-sm font-medium",
173
- # invalid_label: "text-red-700 dark:text-red-500",
174
- # valid_label: "text-green-700 dark:text-green-500",
175
- # neutral_label: "text-gray-700 dark:text-white",
176
- # # input themes
177
- # input: "w-full p-2 border rounded-md shadow-sm font-medium text-sm dark:bg-gray-700",
178
- # invalid_input: "bg-red-50 border-red-500 dark:border-red-500 text-red-900 dark:text-red-500 placeholder-red-700 dark:placeholder-red-500 focus:ring-red-500 focus:border-red-500",
179
- # valid_input: "bg-green-50 border-green-500 dark:border-green-500 text-green-900 dark:text-green-400 placeholder-green-700 dark:placeholder-green-500 focus:ring-green-500 focus:border-green-500",
180
- # neutral_input: "border-gray-300 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white focus:ring-primary-500 focus:border-primary-500",
181
- # # hint themes
182
- # hint: "mt-2 text-sm text-gray-500 dark:text-gray-200",
183
- # # error themes
184
- # error: "mt-2 text-sm text-red-600 dark:text-red-500",
185
- # # wrapper themes
186
- # wrapper: "flex flex-col md:flex-row items-start space-y-2 md:space-y-0 md:space-x-2 mb-4",
187
- # inner_wrapper: "md:w-5/6 w-full",
188
- }.freeze
189
- end
190
-
191
- def reflection = nil
211
+ end
192
212
 
193
213
  def has_value?
194
214
  value.present?
195
215
  end
216
+
217
+ def determine_initial_value(value)
218
+ return value unless value == NIL_VALUE
219
+
220
+ determine_value_from_association || determine_value_from_object
221
+ end
222
+
223
+ def determine_value_from_object
224
+ object.respond_to?(key) ? object.public_send(key) : nil
225
+ end
226
+
227
+ def determine_value_from_association
228
+ return nil unless association_reflection.present?
229
+
230
+ value = object.public_send(key)
231
+ case association_reflection.macro
232
+ when :has_many, :has_and_belongs_to_many
233
+ value&.map { |v| v.public_send(association_reflection.klass.primary_key) }
234
+ when :belongs_to, :has_one
235
+ value&.public_send(association_reflection.klass.primary_key)
236
+ else
237
+ raise ArgumentError, "Unsupported association type: #{association_reflection.macro}"
238
+ end
239
+ end
196
240
  end
197
241
  end
198
242
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "phlex"
4
+
3
5
  module Phlexi
4
6
  module Form
5
7
  module Structure
@@ -7,18 +9,30 @@ module Phlexi
7
9
  include Enumerable
8
10
 
9
11
  class Builder
10
- attr_reader :key
12
+ include Phlex::Helpers
13
+
14
+ attr_reader :key, :index
11
15
 
12
- def initialize(key, field)
16
+ def initialize(key, field, index)
13
17
  @key = key.to_s
14
18
  @field = field
19
+ @index = index
15
20
  end
16
21
 
17
- def field(**)
18
- @field.class.new(key, attributes: @field.attributes, **, parent: @field).tap do |field|
22
+ def field(**options)
23
+ options = mix({input_attributes: @field.input_attributes}, options)
24
+ @field.class.new(key, **options, parent: @field).tap do |field|
19
25
  yield field if block_given?
20
26
  end
21
27
  end
28
+
29
+ def hidden_field_tag(value: "", force: false)
30
+ raise "Attempting to build hidden field on non-first field in a collection" unless index == 0 || force
31
+
32
+ @field.class
33
+ .new("hidden", parent: @field)
34
+ .input_tag(type: :hidden, value:)
35
+ end
22
36
  end
23
37
 
24
38
  def initialize(field:, range:, &)
@@ -35,8 +49,8 @@ module Phlexi
35
49
  end
36
50
 
37
51
  def each(&)
38
- @range.each do |key|
39
- yield Builder.new(key, @field)
52
+ @range.each.with_index do |key, index|
53
+ yield Builder.new(key, @field, index)
40
54
  end
41
55
  end
42
56
  end
@@ -3,22 +3,27 @@
3
3
  module Phlexi
4
4
  module Form
5
5
  module Structure
6
- # A Namespace maps and object to values, but doesn't actually have a value itself. For
6
+ # A Namespace maps an object to values, but doesn't actually have a value itself. For
7
7
  # example, a `User` object or ActiveRecord model could be passed into the `:user` namespace.
8
- # To access the values on a Namespace, the `field` can be called for single values.
9
8
  #
10
- # Additionally, to access namespaces within a namespace, such as if a `User has_many :addresses` in
11
- # ActiveRecord, the `namespace` method can be called which will return another Namespace object and
12
- # set the current Namespace as the parent.
9
+ # To access single values on a Namespace, #field can be used.
10
+ #
11
+ # To access nested objects within a namespace, two methods are available:
12
+ #
13
+ # 1. #nest_one: Used for single nested objects, such as if a `User belongs_to :profile` in
14
+ # ActiveRecord. This method returns another Namespace object.
15
+ #
16
+ # 2. #nest_many: Used for collections of nested objects, such as if a `User has_many :addresses` in
17
+ # ActiveRecord. This method returns a NamespaceCollection object.
13
18
  class Namespace < Structure::Node
14
19
  include Enumerable
15
20
 
16
21
  attr_reader :builder_klass, :object
17
22
 
18
- def initialize(key, parent:, object: nil, builder_klass: Field)
23
+ def initialize(key, parent:, builder_klass:, object: nil)
19
24
  super(key, parent: parent)
20
- @object = object
21
25
  @builder_klass = builder_klass
26
+ @object = object
22
27
  @children = {}
23
28
  yield self if block_given?
24
29
  end
@@ -29,6 +34,10 @@ module Phlexi
29
34
  end
30
35
  end
31
36
 
37
+ def submit_button(key = :submit_button, **, &)
38
+ field(key).submit_button_tag(**, &)
39
+ end
40
+
32
41
  # Creates a `Namespace` child instance with the parent set to the current instance, adds to
33
42
  # the `@children` Hash to ensure duplicate child namespaces aren't created, then calls the
34
43
  # method on the `@object` to get the child object to pass into that namespace.
@@ -37,14 +46,14 @@ module Phlexi
37
46
  # form like this:
38
47
  #
39
48
  # ```ruby
40
- # Superform :user, object: User.new do |form|
41
- # form.nest_one :permission do |permission|
42
- # form.field :role
49
+ # Phlexi::Form(User.new, as: :user) do
50
+ # nest_one :profile do |profile|
51
+ # render profile.field(:gender).input_tag
43
52
  # end
44
53
  # end
45
54
  # ```
46
55
  def nest_one(key, object: nil, &)
47
- object ||= object_for(key: key)
56
+ object ||= object_value_for(key: key)
48
57
  create_child(key, self.class, object:, builder_klass:, &)
49
58
  end
50
59
 
@@ -52,10 +61,10 @@ module Phlexi
52
61
  # an enumerable or array of `Address` classes:
53
62
  #
54
63
  # ```ruby
55
- # Phlexi::Form.new User.new do |form|
56
- # render form.field(:email).input_tag
57
- # render form.field(:name).input_tag
58
- # form.nest_many :addresses do |address|
64
+ # Phlexi::Form(User.new) do
65
+ # render field(:email).input_tag
66
+ # render field(:name).input_tag
67
+ # nest_many :addresses do |address|
59
68
  # render address.field(:street).input_tag
60
69
  # render address.field(:state).input_tag
61
70
  # render address.field(:zip).input_tag
@@ -65,17 +74,20 @@ module Phlexi
65
74
  # The object within the block is a `Namespace` object that maps each object within the enumerable
66
75
  # to another `Namespace` or `Field`.
67
76
  def nest_many(key, collection: nil, &)
68
- collection ||= Array(object_for(key: key))
77
+ collection ||= Array(object_value_for(key: key))
69
78
  create_child(key, NamespaceCollection, collection:, &)
70
79
  end
71
80
 
72
- # Creates a Hash of Hashes and Arrays that represent the fields and collections of the Superform.
73
- # This can be used to safely update ActiveRecord objects without the need for Strong Parameters.
74
- # You will want to make sure that all the fields displayed in the form are ones that you're OK updating
75
- # from the generated hash.
76
- def serialize
77
- each_with_object({}) do |child, hash|
78
- hash[child.key] = child.serialize
81
+ def extract_input(params)
82
+ if params.is_a?(Array)
83
+ each_with_object({}) do |child, hash|
84
+ hash.merge! child.extract_input(params[0])
85
+ end
86
+ else
87
+ input = each_with_object({}) do |child, hash|
88
+ hash.merge! child.extract_input(params[key])
89
+ end
90
+ {key => input}
79
91
  end
80
92
  end
81
93
 
@@ -85,17 +97,22 @@ module Phlexi
85
97
  @children.values.each(&)
86
98
  end
87
99
 
88
- # Assigns a hash to the current namespace and children namespace.
89
- def assign(hash)
90
- each do |child|
91
- child.assign hash[child.key]
100
+ def dom_id
101
+ @dom_id ||= begin
102
+ id = if object.nil?
103
+ nil
104
+ elsif object.class.respond_to?(:primary_key)
105
+ object.public_send(object.class.primary_key) || :new
106
+ elsif object.respond_to?(:id)
107
+ object.id || :new
108
+ end
109
+ [key, id].compact.join("_").underscore
92
110
  end
93
- self
94
111
  end
95
112
 
96
113
  # Creates a root Namespace, which is essentially a form.
97
- def self.root(*, **, &)
98
- new(*, parent: nil, **, &)
114
+ def self.root(*, builder_klass:, **, &)
115
+ new(*, parent: nil, builder_klass:, **, &)
99
116
  end
100
117
 
101
118
  protected
@@ -106,7 +123,7 @@ module Phlexi
106
123
  #
107
124
  # This method could be overwritten if the mapping between the `@object` and `key` name is not
108
125
  # a method call. For example, a `Hash` would be accessed via `user[:email]` instead of `user.send(:email)`
109
- def object_for(key:)
126
+ def object_value_for(key:)
110
127
  @object.send(key) if @object.respond_to? key
111
128
  end
112
129
 
@@ -6,36 +6,36 @@ module Phlexi
6
6
  class NamespaceCollection < Node
7
7
  include Enumerable
8
8
 
9
- def initialize(key, parent:, collection: nil, &)
9
+ def initialize(key, parent:, collection: nil, &block)
10
+ raise ArgumentError, "block is required" unless block.present?
11
+
10
12
  super(key, parent: parent)
11
13
 
12
- @namespaces = enumerate(collection)
13
- each(&) if block_given?
14
+ @collection = collection
15
+ @block = block
16
+ each(&block)
14
17
  end
15
18
 
16
- def serialize
17
- map(&:serialize)
18
- end
19
+ def extract_input(params)
20
+ namespace = build_namespace(0)
21
+ @block.call(namespace)
19
22
 
20
- def assign(array)
21
- # The problem with zip-ing the array is if I need to add new
22
- # elements to it and wrap it in the namespace.
23
- zip(array) do |namespace, hash|
24
- namespace.assign hash
25
- end
23
+ inputs = params[key].map { |param| namespace.extract_input([param]) }
24
+ {key => inputs}
26
25
  end
27
26
 
27
+ private
28
+
28
29
  def each(&)
29
- @namespaces.each(&)
30
+ namespaces.each(&)
30
31
  end
31
32
 
32
- private
33
-
34
- def enumerate(enumerator)
35
- Enumerator.new do |y|
36
- enumerator.each.with_index do |object, key|
37
- y << build_namespace(key, object: object)
38
- end
33
+ # Builds and memoizes namespaces for the collection.
34
+ #
35
+ # @return [Array<Namespace>] An array of namespace objects.
36
+ def namespaces
37
+ @namespaces ||= @collection.map.with_index do |object, key|
38
+ build_namespace(key, object: object)
39
39
  end
40
40
  end
41
41
 
@@ -3,15 +3,25 @@
3
3
  module Phlexi
4
4
  module Form
5
5
  module Structure
6
- # Superclass for Namespace and Field classes. Not much to it other than it has a `name`
7
- # and `parent` node attribute. Think of it as a tree.
6
+ # Superclass for Namespace and Field classes. Represents a node in the form tree structure.
7
+ #
8
+ # @attr_reader [Symbol] key The node's key
9
+ # @attr_reader [Node, nil] parent The node's parent in the tree structure
8
10
  class Node
9
11
  attr_reader :key, :parent
10
12
 
13
+ # Initializes a new Node instance.
14
+ #
15
+ # @param key [Symbol, String] The key for the node
16
+ # @param parent [Node, nil] The parent node
11
17
  def initialize(key, parent:)
12
- @key = key
18
+ @key = :"#{key}"
13
19
  @parent = parent
14
20
  end
21
+
22
+ def inspect
23
+ "<#{self.class.name} key=#{key.inspect} parent=#{id.inspect} />"
24
+ end
15
25
  end
16
26
  end
17
27
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlexi
4
4
  module Form
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end