phlexi-form 0.2.0 → 0.3.0

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