phlexi-form 0.2.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.ruby-version +1 -0
  4. data/Appraisals +13 -0
  5. data/CHANGELOG.md +5 -0
  6. data/CODE_OF_CONDUCT.md +84 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +395 -0
  9. data/Rakefile +14 -0
  10. data/config.ru +9 -0
  11. data/gemfiles/default.gemfile +5 -0
  12. data/gemfiles/default.gemfile.lock +174 -0
  13. data/lib/generators/superform/install/USAGE +8 -0
  14. data/lib/generators/superform/install/install_generator.rb +34 -0
  15. data/lib/generators/superform/install/templates/application_form.rb +31 -0
  16. data/lib/phlexi/form/base.rb +234 -0
  17. data/lib/phlexi/form/components/base.rb +37 -0
  18. data/lib/phlexi/form/components/checkbox.rb +43 -0
  19. data/lib/phlexi/form/components/collection_checkboxes.rb +30 -0
  20. data/lib/phlexi/form/components/collection_radio_buttons.rb +29 -0
  21. data/lib/phlexi/form/components/concerns/has_options.rb +33 -0
  22. data/lib/phlexi/form/components/error.rb +21 -0
  23. data/lib/phlexi/form/components/full_error.rb +21 -0
  24. data/lib/phlexi/form/components/hint.rb +21 -0
  25. data/lib/phlexi/form/components/input.rb +78 -0
  26. data/lib/phlexi/form/components/label.rb +26 -0
  27. data/lib/phlexi/form/components/radio_button.rb +31 -0
  28. data/lib/phlexi/form/components/select.rb +57 -0
  29. data/lib/phlexi/form/components/textarea.rb +34 -0
  30. data/lib/phlexi/form/components/wrapper.rb +31 -0
  31. data/lib/phlexi/form/field_options/autofocus.rb +18 -0
  32. data/lib/phlexi/form/field_options/collection.rb +37 -0
  33. data/lib/phlexi/form/field_options/disabled.rb +18 -0
  34. data/lib/phlexi/form/field_options/errors.rb +82 -0
  35. data/lib/phlexi/form/field_options/hints.rb +22 -0
  36. data/lib/phlexi/form/field_options/labels.rb +28 -0
  37. data/lib/phlexi/form/field_options/length.rb +53 -0
  38. data/lib/phlexi/form/field_options/limit.rb +66 -0
  39. data/lib/phlexi/form/field_options/min_max.rb +92 -0
  40. data/lib/phlexi/form/field_options/multiple.rb +63 -0
  41. data/lib/phlexi/form/field_options/pattern.rb +38 -0
  42. data/lib/phlexi/form/field_options/placeholder.rb +18 -0
  43. data/lib/phlexi/form/field_options/readonly.rb +18 -0
  44. data/lib/phlexi/form/field_options/required.rb +37 -0
  45. data/lib/phlexi/form/field_options/type.rb +155 -0
  46. data/lib/phlexi/form/field_options/validators.rb +48 -0
  47. data/lib/phlexi/form/option_mapper.rb +154 -0
  48. data/lib/phlexi/form/structure/dom.rb +57 -0
  49. data/lib/phlexi/form/structure/field_builder.rb +199 -0
  50. data/lib/phlexi/form/structure/field_collection.rb +45 -0
  51. data/lib/phlexi/form/structure/namespace.rb +123 -0
  52. data/lib/phlexi/form/structure/namespace_collection.rb +48 -0
  53. data/lib/phlexi/form/structure/node.rb +18 -0
  54. data/lib/phlexi/form/version.rb +7 -0
  55. data/lib/phlexi/form.rb +28 -0
  56. data/lib/phlexi-form.rb +3 -0
  57. data/sig/phlexi/form.rbs +6 -0
  58. metadata +243 -0
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "phlex"
4
+
5
+ module Phlexi
6
+ module Form
7
+ module Structure
8
+ class FieldBuilder < Node
9
+ include Phlex::Helpers
10
+ include FieldOptions::Validators
11
+ include FieldOptions::Labels
12
+ include FieldOptions::Hints
13
+ include FieldOptions::Errors
14
+ include FieldOptions::Type
15
+ include FieldOptions::Collection
16
+ include FieldOptions::Placeholder
17
+ include FieldOptions::Required
18
+ include FieldOptions::Autofocus
19
+ include FieldOptions::Disabled
20
+ include FieldOptions::Readonly
21
+ include FieldOptions::Length
22
+ include FieldOptions::MinMax
23
+ include FieldOptions::Pattern
24
+ include FieldOptions::Multiple
25
+ include FieldOptions::Limit
26
+
27
+ attr_reader :dom, :options, :object, :attributes
28
+ attr_accessor :value
29
+ alias_method :serialize, :value
30
+ alias_method :assign, :value=
31
+
32
+ def initialize(key, parent:, object: nil, value: :__i_form_builder_nil_value_i__, attributes: {}, **options)
33
+ key = :"#{key}"
34
+ super(key, parent: parent)
35
+
36
+ @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
43
+ @options = options
44
+ @dom = Structure::DOM.new(field: self)
45
+ end
46
+
47
+ def label_tag(**attributes)
48
+ attributes = self.attributes.deep_merge(attributes)
49
+ label_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :label)
50
+ Components::Label.new(self, class: label_class, **attributes)
51
+ end
52
+
53
+ def input_tag(**attributes)
54
+ attributes = self.attributes.deep_merge(attributes)
55
+ input_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :input)
56
+ Components::Input.new(self, class: input_class, **attributes)
57
+ end
58
+
59
+ def checkbox_tag(**attributes)
60
+ attributes = self.attributes.deep_merge(attributes)
61
+ checkbox_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :checkbox)
62
+ Components::Checkbox.new(self, class: checkbox_class, **attributes)
63
+ end
64
+
65
+ def collection_checkboxes_tag(**attributes, &)
66
+ attributes = self.attributes.deep_merge(attributes)
67
+ collection_checkboxes_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :collection_checkboxes)
68
+ Components::CollectionCheckboxes.new(self, class: collection_checkboxes_class, **attributes, &)
69
+ end
70
+
71
+ def radio_button_tag(**attributes)
72
+ attributes = self.attributes.deep_merge(attributes)
73
+ radio_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :radio)
74
+ Components::RadioButton.new(self, class: radio_class, **attributes)
75
+ end
76
+
77
+ def collection_radio_buttons_tag(**attributes, &)
78
+ attributes = self.attributes.deep_merge(attributes)
79
+ collection_radio_buttons_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :collection_radio_buttons)
80
+ Components::CollectionRadioButtons.new(self, class: collection_radio_buttons_class, **attributes, &)
81
+ end
82
+
83
+ def textarea_tag(**attributes)
84
+ attributes = self.attributes.deep_merge(attributes)
85
+ textarea_class = attributes.delete(:class) || themed_input(attributes.delete(:theme) || :textarea)
86
+ Components::Textarea.new(self, class: textarea_class, **attributes)
87
+ end
88
+
89
+ def select_tag(**attributes)
90
+ attributes = self.attributes.deep_merge(attributes)
91
+ select_class = attributes.delete(:class) || themed_input(attributes.delete(:theme) || :select)
92
+ Components::Select.new(self, class: select_class, **attributes)
93
+ end
94
+
95
+ def hint_tag(**attributes)
96
+ attributes = self.attributes.deep_merge(attributes)
97
+ hint_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :hint)
98
+ Components::Hint.new(self, class: hint_class, **attributes)
99
+ end
100
+
101
+ def error_tag(**attributes)
102
+ attributes = self.attributes.deep_merge(attributes)
103
+ error_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :error)
104
+ Components::Error.new(self, class: error_class, **attributes)
105
+ end
106
+
107
+ def full_error_tag(**attributes)
108
+ attributes = self.attributes.deep_merge(attributes)
109
+ error_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :error)
110
+ Components::FullError.new(self, class: error_class, **attributes)
111
+ end
112
+
113
+ def wrapped(inner: {}, **attributes, &)
114
+ attributes = self.attributes.deep_merge(attributes)
115
+ wrapper_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :wrapper)
116
+ inner[:class] = inner.delete(:class) || themed(inner.delete(:theme) || :inner_wrapper)
117
+ Components::Wrapper.new(self, class: wrapper_class, inner:, **attributes, &)
118
+ end
119
+
120
+ # Wraps a field that's an array of values with a bunch of fields
121
+ #
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
135
+ def multi(range = nil, &)
136
+ range ||= Array(collection)
137
+ FieldCollection.new(field: self, range:, &)
138
+ end
139
+
140
+ private
141
+
142
+ def themed(component)
143
+ tokens(resolve_theme(component), resolve_validity_theme(component)).presence if component
144
+ end
145
+
146
+ def themed_input(input_component)
147
+ themed(input_component) || themed(:input) if input_component
148
+ end
149
+
150
+ def resolve_theme(property)
151
+ options[property] || theme[property]
152
+ end
153
+
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}"
162
+ else
163
+ :"neutral_#{property}"
164
+ end
165
+
166
+ resolve_theme(validity_property)
167
+ end
168
+
169
+ def theme
170
+ @theme ||= {
171
+ # # label themes
172
+ # label: "md:w-1/6 mt-2 block mb-2 text-sm font-medium",
173
+ # invalid_label: "text-red-700 dark:text-red-500",
174
+ # valid_label: "text-green-700 dark:text-green-500",
175
+ # neutral_label: "text-gray-700 dark:text-white",
176
+ # # input themes
177
+ # input: "w-full p-2 border rounded-md shadow-sm font-medium text-sm dark:bg-gray-700",
178
+ # invalid_input: "bg-red-50 border-red-500 dark:border-red-500 text-red-900 dark:text-red-500 placeholder-red-700 dark:placeholder-red-500 focus:ring-red-500 focus:border-red-500",
179
+ # valid_input: "bg-green-50 border-green-500 dark:border-green-500 text-green-900 dark:text-green-400 placeholder-green-700 dark:placeholder-green-500 focus:ring-green-500 focus:border-green-500",
180
+ # neutral_input: "border-gray-300 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white focus:ring-primary-500 focus:border-primary-500",
181
+ # # hint themes
182
+ # hint: "mt-2 text-sm text-gray-500 dark:text-gray-200",
183
+ # # error themes
184
+ # error: "mt-2 text-sm text-red-600 dark:text-red-500",
185
+ # # wrapper themes
186
+ # wrapper: "flex flex-col md:flex-row items-start space-y-2 md:space-y-0 md:space-x-2 mb-4",
187
+ # inner_wrapper: "md:w-5/6 w-full",
188
+ }.freeze
189
+ end
190
+
191
+ def reflection = nil
192
+
193
+ def has_value?
194
+ value.present?
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlexi
4
+ module Form
5
+ module Structure
6
+ class FieldCollection
7
+ include Enumerable
8
+
9
+ class Builder
10
+ attr_reader :key
11
+
12
+ def initialize(key, field)
13
+ @key = key.to_s
14
+ @field = field
15
+ end
16
+
17
+ def field(**)
18
+ @field.class.new(key, attributes: @field.attributes, **, parent: @field).tap do |field|
19
+ yield field if block_given?
20
+ end
21
+ end
22
+ end
23
+
24
+ def initialize(field:, range:, &)
25
+ @field = field
26
+ @range = case range
27
+ when Range, Array
28
+ range
29
+ when Integer
30
+ 1..range
31
+ else
32
+ range.to_a
33
+ end
34
+ each(&) if block_given?
35
+ end
36
+
37
+ def each(&)
38
+ @range.each do |key|
39
+ yield Builder.new(key, @field)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlexi
4
+ module Form
5
+ module Structure
6
+ # A Namespace maps and object to values, but doesn't actually have a value itself. For
7
+ # example, a `User` object or ActiveRecord model could be passed into the `:user` namespace.
8
+ # To access the values on a Namespace, the `field` can be called for single values.
9
+ #
10
+ # Additionally, to access namespaces within a namespace, such as if a `User has_many :addresses` in
11
+ # ActiveRecord, the `namespace` method can be called which will return another Namespace object and
12
+ # set the current Namespace as the parent.
13
+ class Namespace < Structure::Node
14
+ include Enumerable
15
+
16
+ attr_reader :builder_klass, :object
17
+
18
+ def initialize(key, parent:, object: nil, builder_klass: Field)
19
+ super(key, parent: parent)
20
+ @object = object
21
+ @builder_klass = builder_klass
22
+ @children = {}
23
+ yield self if block_given?
24
+ end
25
+
26
+ def field(key, **attributes)
27
+ create_child(key, attributes.delete(:builder_klass) || builder_klass, object: object, **attributes).tap do |field|
28
+ yield field if block_given?
29
+ end
30
+ end
31
+
32
+ # Creates a `Namespace` child instance with the parent set to the current instance, adds to
33
+ # the `@children` Hash to ensure duplicate child namespaces aren't created, then calls the
34
+ # method on the `@object` to get the child object to pass into that namespace.
35
+ #
36
+ # For example, if a `User#permission` returns a `Permission` object, we could map that to a
37
+ # form like this:
38
+ #
39
+ # ```ruby
40
+ # Superform :user, object: User.new do |form|
41
+ # form.nest_one :permission do |permission|
42
+ # form.field :role
43
+ # end
44
+ # end
45
+ # ```
46
+ def nest_one(key, object: nil, &)
47
+ object ||= object_for(key: key)
48
+ create_child(key, self.class, object:, builder_klass:, &)
49
+ end
50
+
51
+ # Wraps an array of objects in Namespace classes. For example, if `User#addresses` returns
52
+ # an enumerable or array of `Address` classes:
53
+ #
54
+ # ```ruby
55
+ # Phlexi::Form.new User.new do |form|
56
+ # render form.field(:email).input_tag
57
+ # render form.field(:name).input_tag
58
+ # form.nest_many :addresses do |address|
59
+ # render address.field(:street).input_tag
60
+ # render address.field(:state).input_tag
61
+ # render address.field(:zip).input_tag
62
+ # end
63
+ # end
64
+ # ```
65
+ # The object within the block is a `Namespace` object that maps each object within the enumerable
66
+ # to another `Namespace` or `Field`.
67
+ def nest_many(key, collection: nil, &)
68
+ collection ||= Array(object_for(key: key))
69
+ create_child(key, NamespaceCollection, collection:, &)
70
+ end
71
+
72
+ # Creates a Hash of Hashes and Arrays that represent the fields and collections of the Superform.
73
+ # This can be used to safely update ActiveRecord objects without the need for Strong Parameters.
74
+ # You will want to make sure that all the fields displayed in the form are ones that you're OK updating
75
+ # from the generated hash.
76
+ def serialize
77
+ each_with_object({}) do |child, hash|
78
+ hash[child.key] = child.serialize
79
+ end
80
+ end
81
+
82
+ # Iterates through the children of the current namespace, which could be `Namespace` or `Field`
83
+ # objects.
84
+ def each(&)
85
+ @children.values.each(&)
86
+ end
87
+
88
+ # Assigns a hash to the current namespace and children namespace.
89
+ def assign(hash)
90
+ each do |child|
91
+ child.assign hash[child.key]
92
+ end
93
+ self
94
+ end
95
+
96
+ # Creates a root Namespace, which is essentially a form.
97
+ def self.root(*, **, &)
98
+ new(*, parent: nil, **, &)
99
+ end
100
+
101
+ protected
102
+
103
+ # Calls the corresponding method on the object for the `key` name, if it exists. For example
104
+ # if the `key` is `email` on `User`, this method would call `User#email` if the method is
105
+ # present.
106
+ #
107
+ # This method could be overwritten if the mapping between the `@object` and `key` name is not
108
+ # a method call. For example, a `Hash` would be accessed via `user[:email]` instead of `user.send(:email)`
109
+ def object_for(key:)
110
+ @object.send(key) if @object.respond_to? key
111
+ end
112
+
113
+ private
114
+
115
+ # Checks if the child exists. If it does then it returns that. If it doesn't, it will
116
+ # build the child.
117
+ def create_child(key, child_class, **kwargs, &block)
118
+ @children.fetch(key) { @children[key] = child_class.new(key, parent: self, **kwargs, &block) }
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlexi
4
+ module Form
5
+ module Structure
6
+ class NamespaceCollection < Node
7
+ include Enumerable
8
+
9
+ def initialize(key, parent:, collection: nil, &)
10
+ super(key, parent: parent)
11
+
12
+ @namespaces = enumerate(collection)
13
+ each(&) if block_given?
14
+ end
15
+
16
+ def serialize
17
+ map(&:serialize)
18
+ end
19
+
20
+ def assign(array)
21
+ # The problem with zip-ing the array is if I need to add new
22
+ # elements to it and wrap it in the namespace.
23
+ zip(array) do |namespace, hash|
24
+ namespace.assign hash
25
+ end
26
+ end
27
+
28
+ def each(&)
29
+ @namespaces.each(&)
30
+ end
31
+
32
+ private
33
+
34
+ def enumerate(enumerator)
35
+ Enumerator.new do |y|
36
+ enumerator.each.with_index do |object, key|
37
+ y << build_namespace(key, object: object)
38
+ end
39
+ end
40
+ end
41
+
42
+ def build_namespace(index, **)
43
+ parent.class.new(index, parent: self, builder_klass: parent.builder_klass, **)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlexi
4
+ module Form
5
+ module Structure
6
+ # Superclass for Namespace and Field classes. Not much to it other than it has a `name`
7
+ # and `parent` node attribute. Think of it as a tree.
8
+ class Node
9
+ attr_reader :key, :parent
10
+
11
+ def initialize(key, parent:)
12
+ @key = key
13
+ @parent = parent
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlexi
4
+ module Form
5
+ VERSION = "0.2.0"
6
+ end
7
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require "phlex"
5
+
6
+ module Phlexi
7
+ module Form
8
+ Loader = Zeitwerk::Loader.new.tap do |loader|
9
+ loader.tag = File.basename(__FILE__, ".rb")
10
+ loader.inflector.inflect(
11
+ "phlexi-form" => "Phlexi",
12
+ "phlexi" => "Phlexi",
13
+ "dom" => "DOM"
14
+ )
15
+ loader.push_dir(File.expand_path("..", __dir__))
16
+ loader.ignore(File.expand_path("../generators", __dir__))
17
+ loader.setup
18
+ end
19
+
20
+ BaseComponent = (defined?(::ApplicationComponent) ? ::ApplicationComponent : Phlex::HTML)
21
+
22
+ class Error < StandardError; end
23
+ end
24
+ end
25
+
26
+ def Phlexi.Form(...)
27
+ Phlexi::Form::Base.new(...)
28
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "phlexi/form"
@@ -0,0 +1,6 @@
1
+ module Phlexi
2
+ module Form
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end