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
@@ -1,29 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bigdecimal"
4
+
3
5
  module Phlexi
4
6
  module Form
5
7
  module FieldOptions
6
- module Type
7
- def db_type
8
- @db_type ||= infer_db_type
8
+ module InferredTypes
9
+ def inferred_db_type
10
+ @inferred_db_type ||= infer_db_type
9
11
  end
10
12
 
11
- def input_component
12
- @input_component ||= infer_input_component
13
+ # This will give you the component type e.g. input, textarea, select
14
+ def inferred_component_type
15
+ @inferred_component_type ||= infer_component_type
13
16
  end
14
17
 
15
- def input_type
16
- @input_type ||= infer_input_type
18
+ # This will give you the subtype of the input component e.g input type="text|date|checkbox" etc
19
+ def inferred_input_component_subtype
20
+ @inferred_input_component_subtype ||= infer_input_component_subtype(inferred_component_type)
17
21
  end
18
22
 
19
23
  private
20
24
 
21
25
  # this returns the element type
22
- # one of :input, :textarea, :select, :botton
23
- def infer_input_component
24
- return :select unless collection.blank?
26
+ # one of :input, :textarea, :select
27
+ def infer_component_type
28
+ return :select unless collection.nil?
25
29
 
26
- case db_type
30
+ case inferred_db_type
27
31
  when :text, :json, :jsonb, :hstore
28
32
  :textarea
29
33
  else
@@ -33,10 +37,8 @@ module Phlexi
33
37
 
34
38
  # this only applies when input_component is `:input`
35
39
  # 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
+ def infer_input_component_subtype(component)
41
+ case inferred_db_type
40
42
  when :string
41
43
  infer_string_input_type(key)
42
44
  when :integer, :float, :decimal
@@ -64,7 +66,7 @@ module Phlexi
64
66
  if object.class.respond_to?(:attribute_types)
65
67
  # ActiveModel::Attributes
66
68
  custom_type = object.class.attribute_types[key.to_s]
67
- return custom_type.type if custom_type
69
+ return custom_type.type if custom_type&.type
68
70
  end
69
71
 
70
72
  # Check if object responds to the key
@@ -81,8 +83,10 @@ module Phlexi
81
83
  case value
82
84
  when Integer
83
85
  :integer
84
- when Float, BigDecimal
86
+ when Float
85
87
  :float
88
+ when BigDecimal
89
+ :decimal
86
90
  when TrueClass, FalseClass
87
91
  :boolean
88
92
  when Date
@@ -16,7 +16,9 @@ module Phlexi
16
16
  private
17
17
 
18
18
  def calculate_multiple_field_value
19
+ return true if association_reflection&.macro == :has_many
19
20
  return true if multiple_field_array_attribute?
21
+
20
22
  check_multiple_field_from_validators
21
23
  end
22
24
 
@@ -24,7 +24,7 @@ module Phlexi
24
24
  end
25
25
 
26
26
  def required_by_validators?
27
- (attribute_validators + reflection_validators).any? { |v| v.kind == :presence && valid_validator?(v) }
27
+ (attribute_validators + association_reflection_validators).any? { |v| v.kind == :presence && valid_validator?(v) }
28
28
  end
29
29
 
30
30
  def required_by_default?
@@ -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
@@ -14,8 +14,8 @@ module Phlexi
14
14
  object.class.validators_on(key)
15
15
  end
16
16
 
17
- def reflection_validators
18
- reflection ? object.class.validators_on(reflection.name) : []
17
+ def association_reflection_validators
18
+ association_reflection ? object.class.validators_on(association_reflection.name) : []
19
19
  end
20
20
 
21
21
  def valid_validator?(validator)
@@ -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.
@@ -5,7 +5,7 @@ module Phlexi
5
5
  module Structure
6
6
  # Generates DOM IDs, names, etc. for a Field, Namespace, or Node based on
7
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.
8
+ # other Ruby web frameworks since it has no dependencies on Rails.
9
9
  class DOM
10
10
  def initialize(field:)
11
11
  @field = field
@@ -17,40 +17,45 @@ module Phlexi
17
17
  @field.value.to_s
18
18
  end
19
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.
20
+ # Walks from the current node to the parent node, grabs the names, and separates
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