phlexi-form 0.2.0

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