protos-protoform 0.0.1

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