phlexi-form 0.2.0 → 0.3.0.rc1

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/Appraisals +4 -9
  3. data/README.md +115 -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 +52 -35
  10. data/lib/phlexi/form/components/base.rb +12 -6
  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/handles_array_input.rb +21 -0
  15. data/lib/phlexi/form/components/concerns/handles_input.rb +53 -0
  16. data/lib/phlexi/form/components/concerns/has_options.rb +6 -2
  17. data/lib/phlexi/form/components/concerns/submits_form.rb +39 -0
  18. data/lib/phlexi/form/components/file_input.rb +32 -0
  19. data/lib/phlexi/form/components/input.rb +39 -33
  20. data/lib/phlexi/form/components/input_array.rb +45 -0
  21. data/lib/phlexi/form/components/label.rb +2 -1
  22. data/lib/phlexi/form/components/radio_button.rb +11 -1
  23. data/lib/phlexi/form/components/select.rb +21 -5
  24. data/lib/phlexi/form/components/submit_button.rb +41 -0
  25. data/lib/phlexi/form/field_options/associations.rb +21 -0
  26. data/lib/phlexi/form/field_options/autofocus.rb +1 -1
  27. data/lib/phlexi/form/field_options/collection.rb +26 -9
  28. data/lib/phlexi/form/field_options/errors.rb +10 -0
  29. data/lib/phlexi/form/field_options/{type.rb → inferred_types.rb} +12 -12
  30. data/lib/phlexi/form/field_options/multiple.rb +2 -0
  31. data/lib/phlexi/form/field_options/themes.rb +207 -0
  32. data/lib/phlexi/form/option_mapper.rb +2 -2
  33. data/lib/phlexi/form/structure/dom.rb +19 -14
  34. data/lib/phlexi/form/structure/field_builder.rb +145 -108
  35. data/lib/phlexi/form/structure/field_collection.rb +14 -5
  36. data/lib/phlexi/form/structure/namespace.rb +31 -19
  37. data/lib/phlexi/form/structure/namespace_collection.rb +20 -20
  38. data/lib/phlexi/form/structure/node.rb +1 -1
  39. data/lib/phlexi/form/version.rb +1 -1
  40. data/lib/phlexi/form.rb +4 -1
  41. metadata +30 -6
  42. data/CODE_OF_CONDUCT.md +0 -84
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlexi
4
+ module Form
5
+ module FieldOptions
6
+ module Themes
7
+ # Resolves theme classes for components based on their type and the validity state of the field.
8
+ #
9
+ # This method is responsible for determining the appropriate CSS classes for a given form component.
10
+ # It considers both the base theme for the component type and any additional theming based on the
11
+ # component's validity state (valid, invalid, or neutral). The method supports a hierarchical
12
+ # theming system, allowing for cascading themes and easy customization.
13
+ #
14
+ # @param component [Symbol, String] The type of form component (e.g., :input, :label, :wrapper)
15
+ #
16
+ # @return [String, nil] A string of CSS classes for the component, or nil if no theme is applied
17
+ #
18
+ # @example Basic usage
19
+ # themed(:input)
20
+ # # => "w-full p-2 border rounded-md shadow-sm font-medium text-sm dark:bg-gray-700"
21
+ #
22
+ # @example Usage with validity state
23
+ # # Assuming the field has errors
24
+ # themed(:input)
25
+ # # => "w-full p-2 border rounded-md shadow-sm font-medium text-sm dark:bg-gray-700 bg-red-50 border-red-500 text-red-900"
26
+ #
27
+ # @example Cascading themes
28
+ # # Assuming textarea inherits from input in the theme definition
29
+ # themed(:textarea)
30
+ # # => "w-full p-2 border rounded-md shadow-sm font-medium text-sm dark:bg-gray-700"
31
+ #
32
+ # @note The actual CSS classes returned will depend on the theme definitions in the `theme` hash
33
+ # and any overrides specified in the `options` hash.
34
+ #
35
+ # @see #resolve_theme
36
+ # @see #resolve_validity_theme
37
+ # @see #theme
38
+ def themed(component)
39
+ return unless component
40
+
41
+ tokens(resolve_theme(component), resolve_validity_theme(component)).presence
42
+ end
43
+
44
+ protected
45
+
46
+ # Recursively resolves the theme for a given property, handling nested symbol references
47
+ #
48
+ # @param property [Symbol, String] The theme property to resolve
49
+ # @param visited [Set] Set of already visited properties to prevent infinite recursion
50
+ # @return [String, nil] The resolved theme value or nil if not found
51
+ #
52
+ # @example Resolving a nested theme
53
+ # # Assuming the theme is: { input: :base_input, base_input: "some-class" }
54
+ # resolve_theme(:input)
55
+ # # => "some-class"
56
+ def resolve_theme(property, visited = Set.new)
57
+ return nil if !property.present? || visited.include?(property)
58
+ visited.add(property)
59
+
60
+ result = theme[property]
61
+ if result.is_a?(Symbol)
62
+ resolve_theme(result, visited)
63
+ else
64
+ result
65
+ end
66
+ end
67
+
68
+ # Resolves the theme for a component based on its current validity state
69
+ #
70
+ # This method determines the validity state of the field (valid, invalid, or neutral)
71
+ # and returns the corresponding theme by prepending the state to the component name.
72
+ #
73
+ # @param property [Symbol, String] The base theme property to resolve
74
+ # @return [String, nil] The resolved validity-specific theme or nil if not found
75
+ #
76
+ # @example Resolving a validity theme
77
+ # # Assuming the field has errors and the theme includes { invalid_input: "error-class" }
78
+ # resolve_validity_theme(:input)
79
+ # # => "error-class"
80
+ def resolve_validity_theme(property)
81
+ validity_property = if has_errors?
82
+ :"invalid_#{property}"
83
+ elsif object_valid?
84
+ :"valid_#{property}"
85
+ else
86
+ :"neutral_#{property}"
87
+ end
88
+
89
+ resolve_theme(validity_property)
90
+ end
91
+
92
+ # Retrieves or initializes the theme hash for the form builder.
93
+ #
94
+ # This method returns a hash containing theme definitions for various form components.
95
+ # If a theme has been explicitly set in the options, it returns that. Otherwise, it
96
+ # initializes and returns a default theme.
97
+ #
98
+ # The theme hash defines CSS classes or references to other theme keys for different
99
+ # components and their states (e.g., valid, invalid, neutral).
100
+ #
101
+ # @return [Hash] A hash containing theme definitions for form components
102
+ #
103
+ # @example Accessing the theme
104
+ # theme[:input]
105
+ # # => "w-full p-2 border rounded-md shadow-sm font-medium text-sm dark:bg-gray-700"
106
+ #
107
+ # @example Accessing a validity-specific theme
108
+ # theme[:invalid_input]
109
+ # # => "bg-red-50 border-red-500 text-red-900 placeholder-red-700 focus:ring-red-500 focus:border-red-500"
110
+ #
111
+ # @example Theme inheritance
112
+ # theme[:textarea] # Returns :input, indicating textarea inherits input's theme
113
+ # theme[:valid_textarea] # Returns :valid_input
114
+ #
115
+ # @note The actual content of the theme hash depends on the default_theme method
116
+ # and any theme overrides specified in the options when initializing the field builder.
117
+ #
118
+ # @see #default_theme
119
+ def theme
120
+ @theme ||= options[:theme] || default_theme
121
+ end
122
+
123
+ # Defines and returns the default theme hash for the field builder.
124
+ #
125
+ # This method returns a hash containing the base theme definitions for various components.
126
+ # It sets up the default styling and relationships between different components and their states.
127
+ # The theme uses a combination of explicit CSS classes and symbolic references to other theme keys,
128
+ # allowing for a flexible and inheritance-based theming system.
129
+ #
130
+ # @return [Hash] A frozen hash containing default theme definitions for components
131
+ #
132
+ # @example Accessing the default theme
133
+ # default_theme[:input]
134
+ # # => nil (indicates that :input doesn't have a default and should be defined by the user)
135
+ #
136
+ # @example Theme inheritance
137
+ # default_theme[:textarea]
138
+ # # => :input (indicates that :textarea inherits from :input)
139
+ #
140
+ # @example Validity state theming
141
+ # default_theme[:valid_textarea]
142
+ # # => :valid_input (indicates that :valid_textarea inherits from :valid_input)
143
+ #
144
+ # @note This method returns a frozen hash to prevent accidental modifications.
145
+ # To customize the theme, users should provide their own theme hash when initializing the field builder.
146
+ # @note Most theme values are set to nil or commented out in the default theme to encourage users
147
+ # to define their own styles while maintaining the relationships between components and states.
148
+ #
149
+ # @see #theme
150
+ def default_theme
151
+ {
152
+ # # input
153
+ # input: nil,
154
+ # valid_input: nil,
155
+ # invalid_input: nil,
156
+ # neutral_input: nil,
157
+
158
+ # textarea
159
+ textarea: :input,
160
+ valid_textarea: :valid_input,
161
+ invalid_textarea: :invalid_input,
162
+ neutral_textarea: :neutral_input,
163
+
164
+ # select
165
+ select: :input,
166
+ valid_select: :valid_input,
167
+ invalid_select: :invalid_input,
168
+ neutral_select: :neutral_input,
169
+
170
+ # file
171
+ file: :input,
172
+ valid_file: :valid_input,
173
+ invalid_file: :invalid_input,
174
+ neutral_file: :neutral_input,
175
+
176
+ # misc
177
+ # label: nil,
178
+ # hint: nil,
179
+ # error: nil,
180
+ full_error: :error,
181
+ # wrapper: nil,
182
+ # inner_wrapper: nil,
183
+ submit_button: :button
184
+
185
+ # # label themes
186
+ # label: "md:w-1/6 mt-2 block mb-2 text-sm font-medium",
187
+ # invalid_label: "text-red-700 dark:text-red-500",
188
+ # valid_label: "text-green-700 dark:text-green-500",
189
+ # neutral_label: "text-gray-700 dark:text-white",
190
+ # # input themes
191
+ # input: "w-full p-2 border rounded-md shadow-sm font-medium text-sm dark:bg-gray-700",
192
+ # 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",
193
+ # 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",
194
+ # neutral_input: "border-gray-300 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white focus:ring-primary-500 focus:border-primary-500",
195
+ # # hint themes
196
+ # hint: "mt-2 text-sm text-gray-500 dark:text-gray-200",
197
+ # # error themes
198
+ # error: "mt-2 text-sm text-red-600 dark:text-red-500",
199
+ # # wrapper themes
200
+ # wrapper: "flex flex-col md:flex-row items-start space-y-2 md:space-y-0 md:space-x-2 mb-4",
201
+ # inner_wrapper: "md:w-5/6 w-full",
202
+ }.freeze
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
@@ -93,7 +93,7 @@ module Phlexi
93
93
  else
94
94
  array_to_hash(Array(collection))
95
95
  end
96
- rescue ArgumentError => e
96
+ rescue ArgumentError
97
97
  # Rails.logger.warn("Unhandled inclusion collection type: #{e}")
98
98
  {}
99
99
  end
@@ -142,7 +142,7 @@ module Phlexi
142
142
 
143
143
  # @return [Array<Symbol>] An array of method names to try for collection values.
144
144
  def collection_value_methods
145
- @collection_value_methods ||= %i[to_param id to_s].freeze
145
+ @collection_value_methods ||= %i[id to_s].freeze
146
146
  end
147
147
 
148
148
  # @return [Array<Symbol>] An array of method names to try for collection labels.
@@ -18,39 +18,44 @@ module Phlexi
18
18
  end
19
19
 
20
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.
21
+ # them with a `_` for a DOM ID.
23
22
  def id
24
- lineage.map(&:key).join("_")
23
+ @id ||= begin
24
+ root, *rest = lineage
25
+ root_key = root.respond_to?(:dom_id) ? root.dom_id : root.key
26
+ rest.map(&:key).unshift(root_key).join("_")
27
+ end
25
28
  end
26
29
 
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
30
+ # The `name` attribute of a node, which is influenced by Rails.
31
+ # All node names, except the parent node, are wrapped in a `[]` and collections
29
32
  # are left empty. For example, `user[addresses][][street]` would be created for a form with
30
33
  # data shaped like `{user: {addresses: [{street: "Sesame Street"}]}}`.
31
34
  def name
32
- root, *names = keys
33
- names.map { |name| "[#{name}]" }.unshift(root).join
35
+ @name ||= begin
36
+ root, *names = keys
37
+ names.map { |name| "[#{name}]" }.unshift(root).join
38
+ end
39
+ end
40
+
41
+ # One-liner way of walking from the current node all the way up to the parent.
42
+ def lineage
43
+ @lineage ||= Enumerator.produce(@field, &:parent).take_while(&:itself).reverse
34
44
  end
35
45
 
36
46
  # Emit the id, name, and value in an HTML tag-ish that doesnt have an element.
37
47
  def inspect
38
- "<id=#{id.inspect} name=#{name.inspect} value=#{value.inspect}/>"
48
+ "<#{self.class.name} id=#{id.inspect} name=#{name.inspect} value=#{value.inspect}/>"
39
49
  end
40
50
 
41
51
  private
42
52
 
43
53
  def keys
44
- lineage.map do |node|
54
+ @keys ||= lineage.map do |node|
45
55
  # If the parent of a field is a field, the name should be nil.
46
56
  node.key unless node.parent.is_a? FieldBuilder
47
57
  end
48
58
  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
59
  end
55
60
  end
56
61
  end
@@ -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,203 @@ 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
 
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.
47
60
  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)
61
+ create_component(Components::Label, :label, **attributes)
51
62
  end
52
63
 
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.
53
68
  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)
69
+ create_component(Components::Input, :input, **attributes)
57
70
  end
58
71
 
72
+ def file_input_tag(**attributes)
73
+ create_component(Components::FileInput, :file, **attributes)
74
+ end
75
+
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.
59
80
  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)
81
+ create_component(Components::Checkbox, :checkbox, **attributes)
63
82
  end
64
83
 
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.
65
89
  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, &)
90
+ create_component(Components::CollectionCheckboxes, :collection_checkboxes, **attributes, &)
69
91
  end
70
92
 
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.
71
97
  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)
98
+ create_component(Components::RadioButton, :radio, **attributes)
75
99
  end
76
100
 
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.
77
106
  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, &)
107
+ create_component(Components::CollectionRadioButtons, :collection_radio_buttons, **attributes, &)
81
108
  end
82
109
 
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.
83
114
  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)
115
+ create_component(Components::Textarea, :textarea, **attributes)
87
116
  end
88
117
 
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.
89
122
  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)
123
+ create_component(Components::Select, :select, **attributes)
124
+ end
125
+
126
+ def input_array_tag(**attributes)
127
+ create_component(Components::InputArray, :array, **attributes)
93
128
  end
94
129
 
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.
95
134
  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)
135
+ create_component(Components::Hint, :hint, **attributes)
99
136
  end
100
137
 
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.
101
142
  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)
143
+ create_component(Components::Error, :error, **attributes)
105
144
  end
106
145
 
146
+ # Creates a full error tag for the field.
147
+ #
148
+ # @param attributes [Hash] Additional attributes for the full error.
149
+ # @return [Components::FullError] The full error component.
107
150
  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)
151
+ create_component(Components::FullError, :full_error, **attributes)
111
152
  end
112
153
 
154
+ # Wraps the field with additional markup.
155
+ #
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.
113
160
  def wrapped(inner: {}, **attributes, &)
114
- attributes = self.attributes.deep_merge(attributes)
115
161
  wrapper_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :wrapper)
116
162
  inner[:class] = inner.delete(:class) || themed(inner.delete(:theme) || :inner_wrapper)
117
- Components::Wrapper.new(self, class: wrapper_class, inner:, **attributes, &)
163
+ Components::Wrapper.new(self, class: wrapper_class, inner: inner, **attributes, &)
118
164
  end
119
165
 
120
- # Wraps a field that's an array of values with a bunch of fields
166
+ # Creates a multi-value field collection.
121
167
  #
122
- # @example Usage
123
- #
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
168
+ # @param range [Integer, #to_a] The range of keys for each field. If an integer is passed, keys will begin from 1.
169
+ # @yield [block] The block to be executed for each item in the collection.
170
+ # @return [FieldCollection] The field collection.
135
171
  def multi(range = nil, &)
136
- range ||= Array(collection)
137
- FieldCollection.new(field: self, range:, &)
172
+ FieldCollection.new(field: self, range: range, &)
138
173
  end
139
174
 
140
- private
141
-
142
- def themed(component)
143
- tokens(resolve_theme(component), resolve_validity_theme(component)).presence if component
175
+ # Creates a submit button
176
+ #
177
+ # @param attributes [Hash] Additional attributes for the submit.
178
+ # @return [Components::SubmitButton] The submit button component.
179
+ def submit_button_tag(**attributes, &)
180
+ create_component(Components::SubmitButton, :submit_button, **attributes, &)
144
181
  end
145
182
 
146
- def themed_input(input_component)
147
- themed(input_component) || themed(:input) if input_component
148
- end
183
+ def extract_input(params)
184
+ raise "field##{dom.name} did not define an input component" unless @field_input_component
149
185
 
150
- def resolve_theme(property)
151
- options[property] || theme[property]
186
+ @field_input_component.extract_input(params)
152
187
  end
153
188
 
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}"
189
+ protected
190
+
191
+ def create_component(component_class, theme_key, **attributes)
192
+ if component_class.include?(Phlexi::Form::Components::Concerns::HandlesInput)
193
+ raise "input component already defined: #{@field_input_component.inspect}" if @field_input_component
194
+
195
+ attributes = input_attributes.deep_merge(attributes)
196
+ @field_input_component = component_class.new(self, class: component_class_for(theme_key, attributes), **attributes)
162
197
  else
163
- :"neutral_#{property}"
198
+ component_class.new(self, class: component_class_for(theme_key, attributes), **attributes)
164
199
  end
200
+ end
165
201
 
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
202
+ def component_class_for(theme_key, attributes)
203
+ attributes.delete(:class) || themed(attributes.key?(:theme) ? attributes.delete(:theme) : theme_key)
204
+ end
192
205
 
193
206
  def has_value?
194
207
  value.present?
195
208
  end
209
+
210
+ def determine_initial_value(value)
211
+ return value unless value == NIL_VALUE
212
+
213
+ determine_from_association || determine_value_from_object
214
+ end
215
+
216
+ def determine_value_from_object
217
+ object.respond_to?(key) ? object.public_send(key) : nil
218
+ end
219
+
220
+ def determine_from_association
221
+ return nil unless reflection.present?
222
+
223
+ value = object.public_send(key)
224
+ case reflection.macro
225
+ when :has_many, :has_and_belongs_to_many
226
+ value&.map { |v| v.public_send(reflection.klass.primary_key) }
227
+ when :belongs_to, :has_one
228
+ value&.public_send(reflection.klass.primary_key)
229
+ else
230
+ raise ArgumentError, "Unsupported association type: #{reflection.macro}"
231
+ end
232
+ end
196
233
  end
197
234
  end
198
235
  end
@@ -7,18 +7,27 @@ module Phlexi
7
7
  include Enumerable
8
8
 
9
9
  class Builder
10
- attr_reader :key
10
+ attr_reader :key, :index
11
11
 
12
- def initialize(key, field)
12
+ def initialize(key, field, index)
13
13
  @key = key.to_s
14
14
  @field = field
15
+ @index = index
15
16
  end
16
17
 
17
18
  def field(**)
18
- @field.class.new(key, attributes: @field.attributes, **, parent: @field).tap do |field|
19
+ @field.class.new(key, input_attributes: @field.input_attributes, **, parent: @field).tap do |field|
19
20
  yield field if block_given?
20
21
  end
21
22
  end
23
+
24
+ def hidden_field_tag(value: "", force: false)
25
+ raise "Attempting to build hidden field on non-first field in a collection" unless index == 0 || force
26
+
27
+ @field.class
28
+ .new("hidden", parent: @field)
29
+ .input_tag(type: :hidden, value:)
30
+ end
22
31
  end
23
32
 
24
33
  def initialize(field:, range:, &)
@@ -35,8 +44,8 @@ module Phlexi
35
44
  end
36
45
 
37
46
  def each(&)
38
- @range.each do |key|
39
- yield Builder.new(key, @field)
47
+ @range.each.with_index do |key, index|
48
+ yield Builder.new(key, @field, index)
40
49
  end
41
50
  end
42
51
  end