superform 0.5.0 → 0.6.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.
@@ -0,0 +1,51 @@
1
+ module Superform
2
+ # Generates DOM IDs, names, etc. for a Field, Namespace, or Node based on
3
+ # norms that were established by Rails. These can be used outsidef or Rails in
4
+ # other Ruby web frameworks since it has now dependencies on Rails.
5
+ class DOM
6
+ def initialize(field:)
7
+ @field = field
8
+ end
9
+
10
+ # Converts the value of the field to a String, which is required to work
11
+ # with Phlex. Assumes that `Object#to_s` emits a format suitable for the web form.
12
+ def value
13
+ @field.value.to_s
14
+ end
15
+
16
+ # Walks from the current node to the parent node, grabs the names, and seperates
17
+ # them with a `_` for a DOM ID. One limitation of this approach is if multiple forms
18
+ # exist on the same page, the ID may be duplicate.
19
+ def id
20
+ lineage.map(&:key).join("_")
21
+ end
22
+
23
+ # The `name` attribute of a node, which is influenced by Rails (not sure where Rails got
24
+ # it from). All node names, except the parent node, are wrapped in a `[]` and collections
25
+ # are left empty. For example, `user[addresses][][street]` would be created for a form with
26
+ # data shaped like `{user: {addresses: [{street: "Sesame Street"}]}}`.
27
+ def name
28
+ root, *names = keys
29
+ names.map { |name| "[#{name}]" }.unshift(root).join
30
+ end
31
+
32
+ # Emit the id, name, and value in an HTML tag-ish that doesnt have an element.
33
+ def inspect
34
+ "<id=#{id.inspect} name=#{name.inspect} value=#{value.inspect}/>"
35
+ end
36
+
37
+ private
38
+
39
+ def keys
40
+ lineage.map do |node|
41
+ # If the parent of a field is a field, the name should be nil.
42
+ node.key unless node.parent.is_a? Field
43
+ end
44
+ end
45
+
46
+ # One-liner way of walking from the current node all the way up to the parent.
47
+ def lineage
48
+ Enumerator.produce(@field, &:parent).take_while(&:itself).reverse
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,121 @@
1
+ module Superform
2
+ # A Field represents the data associated with a form element. This class provides
3
+ # methods for accessing and modifying the field's value. HTML concerns are all
4
+ # delegated to the DOM object.
5
+ class Field < Node
6
+ attr_reader :dom
7
+
8
+ def initialize(key, parent:, object: nil, value: nil)
9
+ super key, parent: parent
10
+ @object = object
11
+ @value = value
12
+ @dom = Superform::DOM.new(field: self)
13
+ yield self if block_given?
14
+ end
15
+
16
+ def value
17
+ if @object and @object.respond_to? @key
18
+ @object.send @key
19
+ else
20
+ @value
21
+ end
22
+ end
23
+ alias :serialize :value
24
+
25
+ def assign(value)
26
+ if @object and @object.respond_to? "#{@key}="
27
+ @object.send "#{@key}=", value
28
+ else
29
+ @value = value
30
+ end
31
+ end
32
+ alias :value= :assign
33
+
34
+ # Wraps a field that's an array of values with a bunch of fields
35
+ # that are indexed with the array's index.
36
+ def collection(&)
37
+ @collection ||= FieldCollection.new(field: self, &)
38
+ end
39
+
40
+ # Make the name more obvious for extending or writing docs.
41
+ def field
42
+ self
43
+ end
44
+
45
+ # High-performance Kit proxy that wraps field methods with form.render calls.
46
+ # Uses Ruby class hooks to define methods at the class level for maximum speed:
47
+ # - Methods are defined once per Field class, not per Kit instance
48
+ # - True Ruby methods with full VM optimization (no method_missing overhead)
49
+ # - ~125x faster Kit instantiation compared to instance-level dynamic methods
50
+ # - Each Field subclass gets its own isolated Kit class with true isolation:
51
+ # * Methods are copied at subclass creation time, not inherited dynamically
52
+ # * Adding methods to a parent Field class won't affect existing subclass Kits
53
+ # * Perfect for library design where you don't want parent changes affecting subclasses
54
+ class Kit
55
+ def initialize(field:, form:)
56
+ @field = field
57
+ @form = form
58
+ end
59
+ end
60
+
61
+ def self.inherited(subclass)
62
+ super
63
+ # Create a new Kit class for each Field subclass with true isolation
64
+ # Copy methods from parent Field classes at creation time, not through inheritance
65
+ subclass.const_set(:Kit, Class.new(Field::Kit))
66
+
67
+ # Copy all existing methods from the inheritance chain
68
+ field_class = self
69
+ while field_class != Field
70
+ copy_field_methods_to_kit(field_class, subclass::Kit)
71
+ field_class = field_class.superclass
72
+ end
73
+ end
74
+
75
+ def self.method_added(method_name)
76
+ super
77
+ # Skip if this is the base Field class or if we don't have a Kit class yet
78
+ return if self == Field
79
+ return unless const_defined?(:Kit, false)
80
+
81
+ # Only add method to THIS class's Kit, not subclasses (isolation)
82
+ add_method_to_kit(method_name, self::Kit)
83
+ end
84
+
85
+ def kit(form)
86
+ self.class::Kit.new(field: self, form: form)
87
+ end
88
+
89
+ private
90
+
91
+ def self.copy_field_methods_to_kit(field_class, kit_class)
92
+ base_methods = (Object.instance_methods + Node.instance_methods +
93
+ [:dom, :value, :serialize, :assign, :collection, :field, :kit]).to_set
94
+
95
+ field_class.instance_methods(false).each do |method_name|
96
+ next if method_name.to_s.end_with?('=')
97
+ next if base_methods.include?(method_name)
98
+ next if kit_class.method_defined?(method_name)
99
+
100
+ kit_class.define_method(method_name) do |*args, **kwargs, &block|
101
+ result = @field.send(method_name, *args, **kwargs, &block)
102
+ @form.render result
103
+ end
104
+ end
105
+ end
106
+
107
+ def self.add_method_to_kit(method_name, kit_class)
108
+ return if method_name.to_s.end_with?('=')
109
+
110
+ base_methods = (Object.instance_methods + Node.instance_methods +
111
+ [:dom, :value, :serialize, :assign, :collection, :field, :kit]).to_set
112
+ return if base_methods.include?(method_name)
113
+ return if kit_class.method_defined?(method_name)
114
+
115
+ kit_class.define_method(method_name) do |*args, **kwargs, &block|
116
+ result = @field.send(method_name, *args, **kwargs, &block)
117
+ @form.render result
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,33 @@
1
+ module Superform
2
+ # A FieldCollection represents values that are collections of literals. For example, a Note
3
+ # ActiveRecord object might have a collection of tags that's an array of string literals.
4
+ class FieldCollection
5
+ include Enumerable
6
+
7
+ def initialize(field:, &)
8
+ @field = field
9
+ @index = 0
10
+ each(&) if block_given?
11
+ end
12
+
13
+ def each(&)
14
+ values.each do |value|
15
+ yield build_field(value: value)
16
+ end
17
+ end
18
+
19
+ def field
20
+ build_field
21
+ end
22
+
23
+ def values
24
+ Array(@field.value)
25
+ end
26
+
27
+ private
28
+
29
+ def build_field(**)
30
+ @field.class.new(@index += 1, parent: @field, **)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "phlex"
4
+
5
+ module Superform
6
+ # A basic form component that inherits from Phlex::HTML and wraps content in a form tag.
7
+ # This provides a simple foundation for building forms without Rails dependencies.
8
+ #
9
+ # Example usage:
10
+ # class MyForm < Superform::Form
11
+ # def view_template
12
+ # div { "Form content goes here" }
13
+ # end
14
+ # end
15
+ #
16
+ # form = MyForm.new(action: "/users", method: :post)
17
+ # form.call # renders <form action="/users" method="post">...</form>
18
+ class Form < Phlex::HTML
19
+ def initialize(action: nil, method: :post, **attributes)
20
+ @action = action
21
+ @method = method
22
+ @attributes = attributes
23
+ super()
24
+ end
25
+
26
+ def around_template(&block)
27
+ form(action: @action, method: @method, **@attributes, &block)
28
+ end
29
+
30
+ def build_field(key, parent:, object: nil, &block)
31
+ Field.new(key, parent: parent, object: object, &block)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,134 @@
1
+ module Superform
2
+ # A Namespace maps and object to values, but doesn't actually have a value itself. For
3
+ # example, a `User` object or ActiveRecord model could be passed into the `:user` namespace.
4
+ # To access the values on a Namespace, the `field` can be called for single values.
5
+ #
6
+ # Additionally, to access namespaces within a namespace, such as if a `User has_many :addresses` in
7
+ # ActiveRecord, the `namespace` method can be called which will return another Namespace object and
8
+ # set the current Namespace as the parent.
9
+ class Namespace < Node
10
+ include Enumerable
11
+
12
+ attr_reader :object, :form
13
+
14
+ def initialize(key, parent:, object: nil, form: Superform::Form.new)
15
+ super(key, parent:)
16
+ @object = object
17
+ @form = form
18
+ @children = Hash.new
19
+ yield self if block_given?
20
+ end
21
+
22
+ # Creates a `Namespace` child instance with the parent set to the current instance, adds to
23
+ # the `@children` Hash to ensure duplicate child namespaces aren't created, then calls the
24
+ # method on the `@object` to get the child object to pass into that namespace.
25
+ #
26
+ # For example, if a `User#permission` returns a `Permission` object, we could map that to a
27
+ # form like this:
28
+ #
29
+ # ```ruby
30
+ # Superform :user, object: User.new do |form|
31
+ # form.namespace :permission do |permission|
32
+ # form.field :role
33
+ # end
34
+ # end
35
+ # ```
36
+ def namespace(key, &)
37
+ @children[key] ||= build_namespace(key, &)
38
+ end
39
+
40
+ # Maps the `Object#proprety` and `Object#property=` to a field in a web form that can be
41
+ # read and set by the form. For example, a User form might look like this:
42
+ #
43
+ # ```ruby
44
+ # Superform :user, object: User.new do |form|
45
+ # form.field :email
46
+ # form.field :name
47
+ # end
48
+ # ```
49
+ def field(key, &)
50
+ @children[key] ||= build_field(key, &)
51
+ end
52
+
53
+ def Field(...)
54
+ field(...).kit(@form)
55
+ end
56
+ # Wraps an array of objects in Namespace classes. For example, if `User#addresses` returns
57
+ # an enumerable or array of `Address` classes:
58
+ #
59
+ # ```ruby
60
+ # Superform :user, object: User.new do |form|
61
+ # form.field :email
62
+ # form.field :name
63
+ # form.collection :addresses do |address|
64
+ # address.field(:street)
65
+ # address.field(:state)
66
+ # address.field(:zip)
67
+ # end
68
+ # end
69
+ # ```
70
+ # The object within the block is a `Namespace` object that maps each object within the enumerable
71
+ # to another `Namespace` or `Field`.
72
+ def collection(key, &)
73
+ @children[key] ||= build_collection(key, &)
74
+ end
75
+
76
+ # Creates a Hash of Hashes and Arrays that represent the fields and collections of the Superform.
77
+ # This can be used to safely update ActiveRecord objects without the need for Strong Parameters.
78
+ # You will want to make sure that all the fields displayed in the form are ones that you're OK updating
79
+ # from the generated hash.
80
+ def serialize
81
+ each_with_object Hash.new do |child, hash|
82
+ hash[child.key] = child.serialize
83
+ end
84
+ end
85
+
86
+ # Iterates through the children of the current namespace, which could be `Namespace` or `Field`
87
+ # objects.
88
+ def each(&)
89
+ @children.values.each(&)
90
+ end
91
+
92
+ # Assigns a hash to the current namespace and children namespace.
93
+ def assign(hash)
94
+ each do |child|
95
+ child.assign hash[child.key]
96
+ end
97
+ self
98
+ end
99
+
100
+ # Creates a root Namespace, which is essentially a form.
101
+ def self.root(*, **, &)
102
+ new(*, parent: nil, **, &)
103
+ end
104
+
105
+ protected
106
+
107
+ # Calls the corresponding method on the object for the `key` name, if it exists. For example
108
+ # if the `key` is `email` on `User`, this method would call `User#email` if the method is
109
+ # present.
110
+ #
111
+ # This method could be overwritten if the mapping between the `@object` and `key` name is not
112
+ # a method call. For example, a `Hash` would be accessed via `user[:email]` instead of `user.send(:email)`
113
+ def object_for(key:)
114
+ @object.send(key) if @object.respond_to? key
115
+ end
116
+
117
+ private
118
+
119
+ # Builds a new field child
120
+ def build_field(key, &)
121
+ @form.build_field(key, parent: self, object:, &)
122
+ end
123
+
124
+ # Builds a new namespace child
125
+ def build_namespace(key, &)
126
+ self.class.new(key, parent: self, object: object_for(key:), form:, &)
127
+ end
128
+
129
+ # Builds a new collection child
130
+ def build_collection(key, &)
131
+ NamespaceCollection.new(key, parent: self, form:, &)
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,51 @@
1
+ module Superform
2
+ # A NamespaceCollection represents values that are collections of namespaces. For example, a User
3
+ # ActiveRecord object might have many Addresses. Each individual address is then delegated out
4
+ # to a Namespace object.
5
+ class NamespaceCollection < Node
6
+ include Enumerable
7
+
8
+ attr_reader :form
9
+
10
+ def initialize(key, parent:, form: parent.form, &template)
11
+ super(key, parent:)
12
+ @template = template
13
+ @form = form
14
+ @namespaces = enumerate(parent_collection)
15
+ end
16
+
17
+ def serialize
18
+ map(&:serialize)
19
+ end
20
+
21
+ def assign(array)
22
+ # The problem with zip-ing the array is if I need to add new
23
+ # elements to it and wrap it in the namespace.
24
+ zip(array) do |namespace, hash|
25
+ namespace.assign hash
26
+ end
27
+ end
28
+
29
+ def each(&)
30
+ @namespaces.each(&)
31
+ end
32
+
33
+ private
34
+
35
+ def enumerate(enumerator)
36
+ Enumerator.new do |y|
37
+ enumerator.each.with_index do |object, key|
38
+ y << build_namespace(key, object:)
39
+ end
40
+ end
41
+ end
42
+
43
+ def build_namespace(index, **)
44
+ parent.class.new(index, parent: self, form:, **, &@template)
45
+ end
46
+
47
+ def parent_collection
48
+ @parent.object.send @key
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,12 @@
1
+ module Superform
2
+ # Superclass for Namespace and Field classes. Not much to it other than it has a `name`
3
+ # and `parent` node attribute. Think of it as a tree.
4
+ class Node
5
+ attr_reader :key, :parent
6
+
7
+ def initialize(key, parent:)
8
+ @key = key
9
+ @parent = parent
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,31 @@
1
+ module Superform
2
+ module Rails
3
+ module Components
4
+ class Base < Component
5
+ attr_reader :field, :dom
6
+
7
+ delegate :dom, to: :field
8
+
9
+ def initialize(field, attributes: {})
10
+ @field = field
11
+ @attributes = attributes
12
+ end
13
+
14
+ def field_attributes
15
+ {}
16
+ end
17
+
18
+ def focus(value = true)
19
+ @attributes[:autofocus] = value
20
+ self
21
+ end
22
+
23
+ private
24
+
25
+ def attributes
26
+ field_attributes.merge(@attributes)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,20 @@
1
+ module Superform
2
+ module Rails
3
+ module Components
4
+ class Button < Field
5
+ def view_template(&content)
6
+ content ||= Proc.new { button_text }
7
+ button(**attributes, &content)
8
+ end
9
+
10
+ def button_text
11
+ @attributes.fetch(:value, dom.value).titleize
12
+ end
13
+
14
+ def field_attributes
15
+ { id: dom.id, name: dom.name, value: dom.value }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ module Superform
2
+ module Rails
3
+ module Components
4
+ class Checkbox < Field
5
+ def view_template(&)
6
+ # Rails has a hidden and checkbox input to deal with sending back a value
7
+ # to the server regardless of if the input is checked or not.
8
+ input(name: dom.name, type: :hidden, value: "0")
9
+ # The hard coded keys need to be in here so the user can't overrite them.
10
+ input(type: :checkbox, value: "1", **attributes)
11
+ end
12
+
13
+ def field_attributes
14
+ { id: dom.id, name: dom.name, checked: field.value }
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,11 @@
1
+ module Superform
2
+ module Rails
3
+ module Components
4
+ class Field < Base
5
+ def field_attributes
6
+ { id: dom.id, name: dom.name }
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,59 @@
1
+ module Superform
2
+ module Rails
3
+ module Components
4
+ class Input < Field
5
+ def view_template(&)
6
+ input(**attributes)
7
+ end
8
+
9
+ def field_attributes
10
+ {
11
+ id: dom.id,
12
+ name: dom.name,
13
+ type: type,
14
+ value: value
15
+ }
16
+ end
17
+
18
+ def has_client_provided_value?
19
+ case type.to_s
20
+ when "file", "image"
21
+ true
22
+ else
23
+ false
24
+ end
25
+ end
26
+
27
+ def value
28
+ dom.value unless has_client_provided_value?
29
+ end
30
+
31
+ def type
32
+ @type ||= ActiveSupport::StringInquirer.new(attribute_type || value_type)
33
+ end
34
+
35
+ protected
36
+ def value_type
37
+ case field.value
38
+ when URI
39
+ "url"
40
+ when Integer, Float
41
+ "number"
42
+ when Date, DateTime
43
+ "date"
44
+ when Time
45
+ "time"
46
+ else
47
+ "text"
48
+ end
49
+ end
50
+
51
+ def attribute_type
52
+ if type = @attributes[:type] || @attributes["type"]
53
+ type.to_s
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,20 @@
1
+ module Superform
2
+ module Rails
3
+ module Components
4
+ class Label < Base
5
+ def view_template(&content)
6
+ content ||= Proc.new { label_text }
7
+ label(**attributes, &content)
8
+ end
9
+
10
+ def field_attributes
11
+ { for: dom.id }
12
+ end
13
+
14
+ def label_text
15
+ field.key.to_s.titleize
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,43 @@
1
+ module Superform
2
+ module Rails
3
+ module Components
4
+ class Select < Field
5
+ def initialize(*, collection: [], **, &)
6
+ super(*, **, &)
7
+ @collection = collection
8
+ end
9
+
10
+ def view_template(&options)
11
+ if block_given?
12
+ select(**attributes, &options)
13
+ else
14
+ select(**attributes) { options(*@collection) }
15
+ end
16
+ end
17
+
18
+ def options(*collection)
19
+ map_options(collection).each do |key, value|
20
+ option(selected: field.value == key, value: key) { value }
21
+ end
22
+ end
23
+
24
+ def blank_option(&)
25
+ option(selected: field.value.nil?, &)
26
+ end
27
+
28
+ def true_option(&)
29
+ option(selected: field.value == true, value: true.to_s, &)
30
+ end
31
+
32
+ def false_option(&)
33
+ option(selected: field.value == false, value: false.to_s, &)
34
+ end
35
+
36
+ protected
37
+ def map_options(collection)
38
+ OptionMapper.new(collection)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,12 @@
1
+ module Superform
2
+ module Rails
3
+ module Components
4
+ class Textarea < Field
5
+ def view_template(&content)
6
+ content ||= Proc.new { dom.value }
7
+ textarea(**attributes, &content)
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end