protos-protoform 0.0.1

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,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protoform
4
+ # A Namespace maps and object to values, but doesn't actually have a value
5
+ # itself. For example, a `User` object or ActiveRecord model could be passed
6
+ # into the `:user` namespace. To access the values on a Namespace, the `field`
7
+ # can be called for single values.
8
+ #
9
+ # Additionally, to access namespaces within a namespace, such as if a `User
10
+ # has_many :addresses` in ActiveRecord, the `namespace` method can be called
11
+ # which will return another Namespace object and set the current Namespace as
12
+ # the parent.
13
+ class Namespace < Node
14
+ include Enumerable
15
+
16
+ attr_reader :object
17
+
18
+ def initialize(key, parent:, object: nil, field_class: Field)
19
+ super(key, parent:)
20
+ @object = object
21
+ @field_class = field_class
22
+ @children = {}
23
+ yield self if block_given?
24
+ end
25
+
26
+ # Creates a `Namespace` child instance with the parent set to the current
27
+ # instance, adds to the `@children` Hash to ensure duplicate child
28
+ # namespaces aren't created, then calls the method on the `@object` to get
29
+ # the child object to pass into that namespace.
30
+ #
31
+ # For example, if a `User#permission` returns a `Permission` object, we
32
+ # could map that to a form like this:
33
+ #
34
+ # ```ruby
35
+ # Protoform :user, object: User.new do |form|
36
+ # form.namespace :permission do |permission|
37
+ # form.field :role
38
+ # end
39
+ # end
40
+ # ```
41
+ def namespace(key, &block)
42
+ create_child(
43
+ key:,
44
+ child_class: self.class,
45
+ field_class: @field_class,
46
+ object: object_for(key:),
47
+ &block
48
+ )
49
+ end
50
+
51
+ # Maps the `Object#proprety` and `Object#property=` to a field in a web form
52
+ # that can be read and set by the form. For example, a User form might look
53
+ # like this:
54
+ #
55
+ # ```ruby
56
+ # Protoform :user, object: User.new do |form|
57
+ # form.field :email
58
+ # form.field :name
59
+ # end
60
+ # ```
61
+ def field(key)
62
+ create_child(
63
+ key:,
64
+ child_class: @field_class,
65
+ object:
66
+ ).tap do |field|
67
+ yield field if block_given?
68
+ end
69
+ end
70
+
71
+ # Wraps an array of objects in Namespace classes. For example, if
72
+ # `User#addresses` returns an enumerable or array of `Address` classes:
73
+ #
74
+ # ```ruby
75
+ # Protoform :user, object: User.new do |form|
76
+ # form.field :email
77
+ # form.field :name
78
+ # form.collection :addresses do |address|
79
+ # address.field(:street)
80
+ # address.field(:state)
81
+ # address.field(:zip)
82
+ # end
83
+ # end
84
+ # ```
85
+ # The object within the block is a `Namespace` object that maps each object
86
+ # within the enumerable to another `Namespace` or `Field`.
87
+ def collection(key, &block)
88
+ create_child(
89
+ key:,
90
+ child_class: NamespaceCollection,
91
+ field_class: @field_class,
92
+ &block
93
+ )
94
+ end
95
+
96
+ # Creates a Hash of Hashes and Arrays that represent the fields and
97
+ # collections of the Protoform. This can be used to safely update
98
+ # ActiveRecord objects without the need for Strong Parameters. You will want
99
+ # to make sure that all the fields displayed in the form are ones that
100
+ # you're OK updating from the generated hash.
101
+ def serialize
102
+ each_with_object({}) do |child, hash|
103
+ hash[child.key] = child.serialize
104
+ end
105
+ end
106
+
107
+ # Iterates through the children of the current namespace, which could be
108
+ # `Namespace` or `Field` objects.
109
+ def each(&block)
110
+ @children.values.each(&block)
111
+ end
112
+
113
+ # Assigns a hash to the current namespace and children namespace.
114
+ def assign(hash)
115
+ tap do
116
+ each do |child|
117
+ child.assign hash[child.key] if hash.key? child.key
118
+ end
119
+ end
120
+ end
121
+
122
+ # Creates a root Namespace, which is essentially a form.
123
+ def self.root(*args, **kwargs, &block)
124
+ new(*args, parent: nil, **kwargs, &block)
125
+ end
126
+
127
+ protected
128
+
129
+ # Calls the corresponding method on the object for the `key` name, if it
130
+ # exists. For example if the `key` is `email` on `User`, this method would
131
+ # call `User#email` if the method is present.
132
+ #
133
+ # This method could be overwritten if the mapping between the `@object` and
134
+ # `key` name is not a method call. For example, a `Hash` would be accessed
135
+ # via `user[:email]` instead of `user.send(:email)`
136
+ def object_for(key:)
137
+ @object.send(key) if @object.respond_to? key
138
+ end
139
+
140
+ private
141
+
142
+ # Checks if the child exists. If it does then it returns that. If it
143
+ # doesn't, it will build the child.
144
+ def create_child(key:, child_class:, **kwargs, &block)
145
+ if (child = @children.fetch(key, nil))
146
+ # ensure that found children are also yielded
147
+ child.tap { yield child if block }
148
+ else
149
+ # new children added to hash and block passed to constructor
150
+ @children[key] = child_class.new(
151
+ key,
152
+ parent: self,
153
+ **kwargs,
154
+ &block
155
+ )
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protoform
4
+ class NamespaceCollection < Node
5
+ include Enumerable
6
+
7
+ def initialize(key, parent:, field_class:, &template)
8
+ super(key, parent:)
9
+ @template = template
10
+ @field_class = field_class
11
+ @namespaces = enumerate(parent_collection)
12
+ end
13
+
14
+ def serialize
15
+ map(&:serialize)
16
+ end
17
+
18
+ def assign(array)
19
+ # The problem with zip-ing the array is if I need to add new
20
+ # elements to it and wrap it in the namespace.
21
+ zip(array) do |namespace, hash|
22
+ namespace.assign hash
23
+ end
24
+ end
25
+
26
+ def each(&block)
27
+ @namespaces.each(&block)
28
+ end
29
+
30
+ private
31
+
32
+ def enumerate(enumerator)
33
+ Enumerator.new do |y|
34
+ enumerator.each.with_index do |object, key|
35
+ y << build_namespace(key, object:)
36
+ end
37
+ end
38
+ end
39
+
40
+ def build_namespace(index, **kwargs)
41
+ parent.class.new(
42
+ index,
43
+ parent: self,
44
+ field_class: @field_class,
45
+ **kwargs,
46
+ &@template
47
+ )
48
+ end
49
+
50
+ def parent_collection
51
+ raise_missing_object unless @parent.respond_to? :object
52
+ @parent.object.send(@key).tap do |value|
53
+ raise_invalid_enumerator(value) unless value.respond_to? :each
54
+ end
55
+ end
56
+
57
+ def raise_invalid_enumerator(value)
58
+ raise(
59
+ ArgumentError,
60
+ <<~ERROR
61
+ #{@parent.object.class} did not return something that responds to
62
+ :each for #{@key}. #{self.class} requires the model to return
63
+ something that can be enumerated for #{@key}.
64
+ Got #{value.class}: #{value.inspect} instead.
65
+ ERROR
66
+ )
67
+ end
68
+
69
+ def raise_missing_object
70
+ raise(
71
+ ArgumentError,
72
+ "Parent of a #{self.class} must respond to :object, " \
73
+ "#{@parent.class} does not"
74
+ )
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protoform
4
+ # Superclass for Namespace and Field classes. Not much to it other than it has
5
+ # a `name` and `parent` node attribute. Think of it as a tree.
6
+ class Node
7
+ attr_reader :key, :parent
8
+
9
+ def initialize(key, parent:)
10
+ @key = key
11
+ @parent = parent
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protoform
4
+ module Rails
5
+ module Components
6
+ class Button < FieldComponent
7
+ def view_template(&content)
8
+ content ||= proc { button_text }
9
+ button(**attrs, &content)
10
+ end
11
+
12
+ private
13
+
14
+ def button_text
15
+ attrs.fetch(:value, dom.value).titleize
16
+ end
17
+
18
+ def default_attrs
19
+ {
20
+ id: dom.id,
21
+ name: dom.name,
22
+ value: dom.value
23
+ }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protoform
4
+ module Rails
5
+ module Components
6
+ class Checkbox < FieldComponent
7
+ def view_template
8
+ # Rails has a hidden and checkbox input to deal with sending back
9
+ # a value to the server regardless of if the input is checked or not.
10
+ input(name: dom.name, type: :hidden, value: "0")
11
+ # The hard coded keys need to be in here so the user can't overrite
12
+ # them.
13
+ input(type: :checkbox, value: "1", **attrs)
14
+ end
15
+
16
+ private
17
+
18
+ def default_attrs
19
+ {
20
+ id: dom.id,
21
+ name: dom.name,
22
+ checked: field.value
23
+ }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protoform
4
+ module Rails
5
+ module Components
6
+ class Component < Protoform::Rails::Component
7
+ param :field
8
+
9
+ def dom
10
+ field.dom
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protoform
4
+ module Rails
5
+ module Components
6
+ class FieldComponent < Component
7
+ private
8
+
9
+ def default_attrs
10
+ {
11
+ id: dom.id,
12
+ name: dom.name
13
+ }
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protoform
4
+ module Rails
5
+ module Components
6
+ class Input < FieldComponent
7
+ option :type, reader: false, default: -> { inferred_type }
8
+
9
+ def view_template
10
+ input(**attrs)
11
+ end
12
+
13
+ private
14
+
15
+ def default_attrs
16
+ {
17
+ id: dom.id,
18
+ name: dom.name,
19
+ type: @type
20
+ }.tap do |hash|
21
+ hash[:value] = field.value unless @type.to_sym == :file
22
+ end
23
+ end
24
+
25
+ def inferred_type
26
+ case field.value
27
+ when URI
28
+ "url"
29
+ when Integer
30
+ "number"
31
+ when Date, DateTime
32
+ "date"
33
+ when Time
34
+ "time"
35
+ else
36
+ "text"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protoform
4
+ module Rails
5
+ module Components
6
+ class Label < Component
7
+ def view_template(&content)
8
+ content ||= proc { field.key.to_s.titleize }
9
+ label(**attrs, &content)
10
+ end
11
+
12
+ private
13
+
14
+ def default_attrs
15
+ {
16
+ for: dom.id
17
+ }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protoform
4
+ module Rails
5
+ module Components
6
+ class Select < FieldComponent
7
+ option :collection, default: -> { [] }
8
+ option :include_blank, default: -> { true }
9
+ option :multiple, reader: false, default: -> { false }
10
+
11
+ def view_template(&options)
12
+ name = @multiple ? "#{attrs[:name]}[]" : attrs[:name]
13
+
14
+ if @multiple
15
+ input(
16
+ name:,
17
+ type: :hidden,
18
+ value: ""
19
+ )
20
+ end
21
+
22
+ if options
23
+ select(multiple: @multiple, **attrs, name:, &options)
24
+ else
25
+ select(multiple: @multiple, **attrs, name:) do
26
+ blank_option
27
+ options(*@collection)
28
+ end
29
+ end
30
+ end
31
+
32
+ def options(*collection)
33
+ map_options(collection).each do |key, value|
34
+ option(
35
+ selected: selected_value_for(key) ? "selected" : false,
36
+ value: key
37
+ ) { value }
38
+ end
39
+ end
40
+
41
+ def blank_option(&block)
42
+ option(selected: field.value.nil?, &block)
43
+ end
44
+
45
+ def true_option(&block)
46
+ option(selected: field.value == true, value: true.to_s, &block)
47
+ end
48
+
49
+ def false_option(&block)
50
+ option(selected: field.value == false, value: false.to_s, &block)
51
+ end
52
+
53
+ protected
54
+
55
+ def selected_value_for(key)
56
+ case field.value
57
+ when String, Symbol
58
+ field.value.to_s == key.to_s
59
+ when Array
60
+ field.value.include?(key)
61
+ else
62
+ field.value == key
63
+ end
64
+ end
65
+
66
+ def map_options(collection)
67
+ OptionMapper.new(collection)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protoform
4
+ module Rails
5
+ module Components
6
+ class Textarea < FieldComponent
7
+ def view_template(&content)
8
+ content ||= proc { dom.value }
9
+ textarea(**attrs, &content)
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protoform
4
+ module Rails
5
+ class Form
6
+ # The Field class is designed to be extended to create custom forms. To
7
+ # override, in your subclass you may have something like this:
8
+ #
9
+ # ```ruby
10
+ # class MyForm < Protoform::Rails::Form
11
+ # class MyLabel < Protoform::Rails::Components::LabelComponent
12
+ # def view_template(&content)
13
+ # label(form: @field.dom.name, class: "text-bold", &content)
14
+ # end
15
+ # end
16
+ #
17
+ # class Field < Field
18
+ # def label(**attributes)
19
+ # MyLabel.new(self, **attributes)
20
+ # end
21
+ # end
22
+ # end
23
+ # ```
24
+ #
25
+ # Now all calls to `label` will have the `text-bold` class applied to it.
26
+ class Field < Protoform::Field
27
+ def button(...)
28
+ Components::Button.new(self, ...)
29
+ end
30
+
31
+ def input(...)
32
+ Components::Input.new(self, ...)
33
+ end
34
+
35
+ def checkbox(...)
36
+ Components::Checkbox.new(self, ...)
37
+ end
38
+
39
+ def label(...)
40
+ Components::Label.new(self, ...)
41
+ end
42
+
43
+ def textarea(...)
44
+ Components::Textarea.new(self, ...)
45
+ end
46
+
47
+ def select(*collection, **attributes, &block)
48
+ Components::Select.new(
49
+ self,
50
+ collection:,
51
+ **attributes,
52
+ &block
53
+ )
54
+ end
55
+
56
+ def title
57
+ key.to_s.titleize
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protoform
4
+ module Rails
5
+ Component = ::ApplicationComponent
6
+ # A Protos::Component class that accepts a model and sets
7
+ # a `Protoform::Namespace` with the `Object#model_name` as the key and maps
8
+ # the object to form fields and namespaces.
9
+ #
10
+ # The `Form::Field` is a class that's meant to be extended so you can
11
+ # customize the `Form` inputs to your applications needs. Defaults for the
12
+ # `input`, `button`, `label`, and `textarea` tags are provided.
13
+ #
14
+ # The `Form` component also handles Rails authenticity tokens via the
15
+ # `authenticity_toklen_field` method and the HTTP verb via the
16
+ # `_method_field`.
17
+ class Form < Component
18
+ param :model, reader: false
19
+ option :helpers, reader: false, default: -> {}
20
+ option :action, reader: false, default: -> {}
21
+ option :method, reader: false, default: -> {}
22
+ option :namespace, reader: false, default: -> do
23
+ Namespace.root(key, object: @model, field_class: self.class::Field)
24
+ end
25
+
26
+ def field(...)
27
+ @namespace.field(...)
28
+ end
29
+
30
+ def collection(...)
31
+ @namespace.collection(...)
32
+ end
33
+
34
+ def namespace(...)
35
+ @namespace.namespace(...)
36
+ end
37
+
38
+ def assign(...)
39
+ @namespace.assign(...)
40
+ end
41
+
42
+ def serialize(...)
43
+ @namespace.serialize(...)
44
+ end
45
+
46
+ def around_template(&block)
47
+ form_tag do
48
+ authenticity_token_field
49
+ _method_field
50
+ super
51
+ end
52
+ end
53
+
54
+ def form_tag(&block)
55
+ form action:, method: form_method, **attrs, &block
56
+ end
57
+
58
+ def view_template(&block)
59
+ yield_content(&block)
60
+ end
61
+
62
+ def submit(value = submit_value, **attributes)
63
+ input(
64
+ **attributes.merge(
65
+ name: "commit",
66
+ type: "submit",
67
+ value:
68
+ )
69
+ )
70
+ end
71
+
72
+ def key
73
+ @model.model_name.param_key
74
+ end
75
+
76
+ protected
77
+
78
+ def authenticity_token_field
79
+ input(
80
+ name: "authenticity_token",
81
+ type: "hidden",
82
+ value: helpers.form_authenticity_token
83
+ )
84
+ end
85
+
86
+ def _method_field
87
+ input(
88
+ name: "_method",
89
+ type: "hidden",
90
+ value: _method_field_value
91
+ )
92
+ end
93
+
94
+ def _method_field_value
95
+ @method || (@model.persisted? ? "patch" : "post")
96
+ end
97
+
98
+ def submit_value
99
+ "#{resource_action.to_s.capitalize} #{@model.model_name}"
100
+ end
101
+
102
+ def resource_action
103
+ @model.persisted? ? :update : :create
104
+ end
105
+
106
+ def action
107
+ @action ||= helpers.url_for(action: resource_action)
108
+ end
109
+
110
+ def form_method
111
+ @method.to_s.downcase == "get" ? "get" : "post"
112
+ end
113
+
114
+ private
115
+
116
+ def helpers
117
+ @helpers ||= super
118
+ end
119
+ end
120
+ end
121
+ end