phlexi-form 0.2.0 → 0.3.0.rc1

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