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.
@@ -1,373 +1,22 @@
1
+ require "phlex/rails"
2
+
1
3
  module Superform
2
4
  module Rails
3
- # The `ApplicationComponent` is the superclass for all components in your application.
4
- Component = ::ApplicationComponent
5
-
6
- # A Phlex::HTML view module that accepts a model and sets a `Superform::Namespace`
7
- # with the `Object#model_name` as the key and maps the object to form fields
8
- # and namespaces.
9
- #
10
- # The `Form::Field` is a class that's meant to be extended so you can customize the `Form` inputs
11
- # to your applications needs. Defaults for the `input`, `button`, `label`, and `textarea` tags
12
- # are provided.
13
- #
14
- # The `Form` component also handles Rails authenticity tokens via the `authenticity_toklen_field`
15
- # method and the HTTP verb via the `_method_field`.
16
- class Form < Component
17
- attr_accessor :model
18
-
19
- delegate \
20
- :field,
21
- :collection,
22
- :namespace,
23
- :assign,
24
- :serialize,
25
- to: :@namespace
26
-
27
- # The Field class is designed to be extended to create custom forms. To override,
28
- # in your subclass you may have something like this:
29
- #
30
- # ```ruby
31
- # class MyForm < Superform::Rails::Form
32
- # class MyLabel < Superform::Rails::Components::LabelComponent
33
- # def view_template(&content)
34
- # label(form: @field.dom.name, class: "text-bold", &content)
35
- # end
36
- # end
37
- #
38
- # class Field < Field
39
- # def label(**attributes)
40
- # MyLabel.new(self, **attributes)
41
- # end
42
- # end
43
- # end
44
- # ```
45
- #
46
- # Now all calls to `label` will have the `text-bold` class applied to it.
47
- class Field < Superform::Field
48
- def button(**attributes)
49
- Components::ButtonComponent.new(self, attributes: attributes)
50
- end
51
-
52
- def input(**attributes)
53
- Components::InputComponent.new(self, attributes: attributes)
54
- end
55
-
56
- def checkbox(**attributes)
57
- Components::CheckboxComponent.new(self, attributes: attributes)
58
- end
59
-
60
- def label(**attributes, &)
61
- Components::LabelComponent.new(self, attributes: attributes, &)
62
- end
63
-
64
- def textarea(**attributes)
65
- Components::TextareaComponent.new(self, attributes: attributes)
66
- end
67
-
68
- def select(*collection, **attributes, &)
69
- Components::SelectField.new(self, attributes: attributes, collection: collection, &)
70
- end
71
-
72
- def title
73
- key.to_s.titleize
74
- end
75
- end
76
-
77
- def initialize(model, action: nil, method: nil, **attributes)
78
- @model = model
79
- @action = action
80
- @method = method
81
- @attributes = attributes
82
- @namespace = Namespace.root(key, object: model, field_class: self.class::Field)
83
- end
84
-
85
- def around_template(&)
86
- form_tag do
87
- authenticity_token_field
88
- _method_field
89
- super
90
- end
91
- end
92
-
93
- def form_tag(&)
94
- form action: form_action, method: form_method, **@attributes, &
95
- end
96
-
97
- def view_template(&block)
98
- yield_content(&block)
99
- end
100
-
101
- def submit(value = submit_value, **attributes)
102
- input **attributes.merge(
103
- name: "commit",
104
- type: "submit",
105
- value: value
106
- )
107
- end
108
-
109
- def key
110
- @model.model_name.param_key
111
- end
112
-
113
- protected
114
- def authenticity_token_field
115
- input(
116
- name: "authenticity_token",
117
- type: "hidden",
118
- value: helpers.form_authenticity_token
119
- )
120
- end
121
-
122
- def _method_field
123
- input(
124
- name: "_method",
125
- type: "hidden",
126
- value: _method_field_value
127
- )
128
- end
129
-
130
- def _method_field_value
131
- @method || resource_method_field_value
132
- end
133
-
134
- def resource_method_field_value
135
- @model.persisted? ? "patch" : "post"
136
- end
137
-
138
- def submit_value
139
- "#{resource_action.to_s.capitalize} #{@model.model_name}"
140
- end
141
-
142
- def resource_action
143
- @model.persisted? ? :update : :create
144
- end
145
-
146
- def form_action
147
- @action ||= helpers.url_for(action: resource_action)
148
- end
149
-
150
- def form_method
151
- @method.to_s.downcase == "get" ? "get" : "post"
152
- end
153
- end
154
-
155
- module StrongParameters
156
- protected
157
- # Assigns params to the form and returns the model.
158
- def assign(params, to:)
159
- form = to
160
- # TODO: Figure out how to render this in a way that doesn't concat a string; just throw everything away.
161
- render_to_string form
162
- form.assign params
163
- form.model
164
- end
165
- end
166
-
167
- # Accept a collection of objects and map them to options suitable for form controls, like `select > options`
168
- class OptionMapper
169
- include Enumerable
170
-
171
- def initialize(collection)
172
- @collection = collection
173
- end
174
-
175
- def each(&options)
176
- @collection.each do |object|
177
- case object
178
- in ActiveRecord::Relation => relation
179
- active_record_relation_options_enumerable(relation).each(&options)
180
- in id, value
181
- options.call id, value
182
- in value
183
- options.call value, value.to_s
184
- end
185
- end
186
- end
187
-
188
- def active_record_relation_options_enumerable(relation)
189
- Enumerator.new do |collection|
190
- relation.each do |object|
191
- attributes = object.attributes
192
- id = attributes.delete(relation.primary_key)
193
- value = attributes.values.join(" ")
194
- collection << [ id, value ]
195
- end
196
- end
197
- end
5
+ # When this is included in a Rails app, check if `Components::Base` is defined and
6
+ # inherit from that so we get all the users stuff; otherwise inherit from Phlex::HTML,
7
+ # which means we won't have all the users methods and overrides.
8
+ SUPERCLASSES = [
9
+ "::Components::Base", # Phlex 2.x base class in a Rails project
10
+ "::ApplicationComponent", # Phlex 1.x base class in a Rails project
11
+ "::Phlex::HTML", # Couldn't detect a base Phlex Rails class, so use Phlex::HTML
12
+ ]
13
+
14
+ # Find the base class for the Rails app.
15
+ def self.base_class
16
+ const_get SUPERCLASSES.find { |const| const_defined?(const) }
198
17
  end
199
18
 
200
- module Components
201
- class BaseComponent < Component
202
- attr_reader :field, :dom
203
-
204
- delegate :dom, to: :field
205
-
206
- def initialize(field, attributes: {})
207
- @field = field
208
- @attributes = attributes
209
- end
210
-
211
- def field_attributes
212
- {}
213
- end
214
-
215
- def focus(value = true)
216
- @attributes[:autofocus] = value
217
- self
218
- end
219
-
220
- private
221
-
222
- def attributes
223
- field_attributes.merge(@attributes)
224
- end
225
- end
226
-
227
- class FieldComponent < BaseComponent
228
- def field_attributes
229
- { id: dom.id, name: dom.name }
230
- end
231
- end
232
-
233
- class LabelComponent < BaseComponent
234
- def view_template(&content)
235
- content ||= Proc.new { field.key.to_s.titleize }
236
- label(**attributes, &content)
237
- end
238
-
239
- def field_attributes
240
- { for: dom.id }
241
- end
242
- end
243
-
244
- class ButtonComponent < FieldComponent
245
- def view_template(&content)
246
- content ||= Proc.new { button_text }
247
- button(**attributes, &content)
248
- end
249
-
250
- def button_text
251
- @attributes.fetch(:value, dom.value).titleize
252
- end
253
-
254
- def field_attributes
255
- { id: dom.id, name: dom.name, value: dom.value }
256
- end
257
- end
258
-
259
- class CheckboxComponent < FieldComponent
260
- def view_template(&)
261
- # Rails has a hidden and checkbox input to deal with sending back a value
262
- # to the server regardless of if the input is checked or not.
263
- input(name: dom.name, type: :hidden, value: "0")
264
- # The hard coded keys need to be in here so the user can't overrite them.
265
- input(type: :checkbox, value: "1", **attributes)
266
- end
267
-
268
- def field_attributes
269
- { id: dom.id, name: dom.name, checked: field.value }
270
- end
271
- end
272
-
273
- class InputComponent < FieldComponent
274
- def view_template(&)
275
- input(**attributes)
276
- end
277
-
278
- def field_attributes
279
- {
280
- id: dom.id,
281
- name: dom.name,
282
- type: type,
283
- value: value
284
- }
285
- end
286
-
287
- def has_client_provided_value?
288
- case type.to_s
289
- when "file", "image"
290
- true
291
- else
292
- false
293
- end
294
- end
295
-
296
- def value
297
- dom.value unless has_client_provided_value?
298
- end
299
-
300
- def type
301
- @type ||= ActiveSupport::StringInquirer.new(attribute_type || value_type)
302
- end
303
-
304
- protected
305
- def value_type
306
- case field.value
307
- when URI
308
- "url"
309
- when Integer, Float
310
- "number"
311
- when Date, DateTime
312
- "date"
313
- when Time
314
- "time"
315
- else
316
- "text"
317
- end
318
- end
319
-
320
- def attribute_type
321
- if type = @attributes[:type] || @attributes["type"]
322
- type.to_s
323
- end
324
- end
325
- end
326
-
327
- class TextareaComponent < FieldComponent
328
- def view_template(&content)
329
- content ||= Proc.new { dom.value }
330
- textarea(**attributes, &content)
331
- end
332
- end
333
-
334
- class SelectField < FieldComponent
335
- def initialize(*, collection: [], **, &)
336
- super(*, **, &)
337
- @collection = collection
338
- end
339
-
340
- def view_template(&options)
341
- if block_given?
342
- select(**attributes, &options)
343
- else
344
- select(**attributes) { options(*@collection) }
345
- end
346
- end
347
-
348
- def options(*collection)
349
- map_options(collection).each do |key, value|
350
- option(selected: field.value == key, value: key) { value }
351
- end
352
- end
353
-
354
- def blank_option(&)
355
- option(selected: field.value.nil?, &)
356
- end
357
-
358
- def true_option(&)
359
- option(selected: field.value == true, value: true.to_s, &)
360
- end
361
-
362
- def false_option(&)
363
- option(selected: field.value == false, value: false.to_s, &)
364
- end
365
-
366
- protected
367
- def map_options(collection)
368
- OptionMapper.new(collection)
369
- end
370
- end
371
- end
19
+ # Set the base class for the rem
20
+ Component = base_class
372
21
  end
373
- end
22
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Superform
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/superform.rb CHANGED
@@ -3,304 +3,13 @@ require "zeitwerk"
3
3
  module Superform
4
4
  Loader = Zeitwerk::Loader.for_gem.tap do |loader|
5
5
  loader.ignore "#{__dir__}/generators"
6
+ loader.inflector.inflect(
7
+ 'dom' => 'DOM'
8
+ )
6
9
  loader.setup
7
10
  end
8
11
 
9
12
  class Error < StandardError; end
10
-
11
- # Generates DOM IDs, names, etc. for a Field, Namespace, or Node based on
12
- # norms that were established by Rails. These can be used outsidef or Rails in
13
- # other Ruby web frameworks since it has now dependencies on Rails.
14
- class DOM
15
- def initialize(field:)
16
- @field = field
17
- end
18
-
19
- # Converts the value of the field to a String, which is required to work
20
- # with Phlex. Assumes that `Object#to_s` emits a format suitable for the web form.
21
- def value
22
- @field.value.to_s
23
- end
24
-
25
- # Walks from the current node to the parent node, grabs the names, and seperates
26
- # them with a `_` for a DOM ID. One limitation of this approach is if multiple forms
27
- # exist on the same page, the ID may be duplicate.
28
- def id
29
- lineage.map(&:key).join("_")
30
- end
31
-
32
- # The `name` attribute of a node, which is influenced by Rails (not sure where Rails got
33
- # it from). All node names, except the parent node, are wrapped in a `[]` and collections
34
- # are left empty. For example, `user[addresses][][street]` would be created for a form with
35
- # data shaped like `{user: {addresses: [{street: "Sesame Street"}]}}`.
36
- def name
37
- root, *names = keys
38
- names.map { |name| "[#{name}]" }.unshift(root).join
39
- end
40
-
41
- # Emit the id, name, and value in an HTML tag-ish that doesnt have an element.
42
- def inspect
43
- "<id=#{id.inspect} name=#{name.inspect} value=#{value.inspect}/>"
44
- end
45
-
46
- private
47
-
48
- def keys
49
- lineage.map do |node|
50
- # If the parent of a field is a field, the name should be nil.
51
- node.key unless node.parent.is_a? Field
52
- end
53
- end
54
-
55
- # One-liner way of walking from the current node all the way up to the parent.
56
- def lineage
57
- Enumerator.produce(@field, &:parent).take_while(&:itself).reverse
58
- end
59
- end
60
-
61
-
62
- # Superclass for Namespace and Field classes. Not much to it other than it has a `name`
63
- # and `parent` node attribute. Think of it as a tree.
64
- class Node
65
- attr_reader :key, :parent
66
-
67
- def initialize(key, parent:)
68
- @key = key
69
- @parent = parent
70
- end
71
- end
72
-
73
- # A Namespace maps and object to values, but doesn't actually have a value itself. For
74
- # example, a `User` object or ActiveRecord model could be passed into the `:user` namespace.
75
- # To access the values on a Namespace, the `field` can be called for single values.
76
- #
77
- # Additionally, to access namespaces within a namespace, such as if a `User has_many :addresses` in
78
- # ActiveRecord, the `namespace` method can be called which will return another Namespace object and
79
- # set the current Namespace as the parent.
80
- class Namespace < Node
81
- include Enumerable
82
-
83
- attr_reader :object
84
-
85
- def initialize(key, parent:, object: nil, field_class: Field)
86
- super(key, parent: parent)
87
- @object = object
88
- @field_class = field_class
89
- @children = Hash.new
90
- yield self if block_given?
91
- end
92
-
93
- # Creates a `Namespace` child instance with the parent set to the current instance, adds to
94
- # the `@children` Hash to ensure duplicate child namespaces aren't created, then calls the
95
- # method on the `@object` to get the child object to pass into that namespace.
96
- #
97
- # For example, if a `User#permission` returns a `Permission` object, we could map that to a
98
- # form like this:
99
- #
100
- # ```ruby
101
- # Superform :user, object: User.new do |form|
102
- # form.namespace :permission do |permission|
103
- # form.field :role
104
- # end
105
- # end
106
- # ```
107
- def namespace(key, &block)
108
- create_child(key, self.class, object: object_for(key: key), &block)
109
- end
110
-
111
- # Maps the `Object#proprety` and `Object#property=` to a field in a web form that can be
112
- # read and set by the form. For example, a User form might look like this:
113
- #
114
- # ```ruby
115
- # Superform :user, object: User.new do |form|
116
- # form.field :email
117
- # form.field :name
118
- # end
119
- # ```
120
- def field(key)
121
- create_child(key, @field_class, object: object).tap do |field|
122
- yield field if block_given?
123
- end
124
- end
125
-
126
- # Wraps an array of objects in Namespace classes. For example, if `User#addresses` returns
127
- # an enumerable or array of `Address` classes:
128
- #
129
- # ```ruby
130
- # Superform :user, object: User.new do |form|
131
- # form.field :email
132
- # form.field :name
133
- # form.collection :addresses do |address|
134
- # address.field(:street)
135
- # address.field(:state)
136
- # address.field(:zip)
137
- # end
138
- # end
139
- # ```
140
- # The object within the block is a `Namespace` object that maps each object within the enumerable
141
- # to another `Namespace` or `Field`.
142
- def collection(key, &)
143
- create_child(key, NamespaceCollection, &)
144
- end
145
-
146
- # Creates a Hash of Hashes and Arrays that represent the fields and collections of the Superform.
147
- # This can be used to safely update ActiveRecord objects without the need for Strong Parameters.
148
- # You will want to make sure that all the fields displayed in the form are ones that you're OK updating
149
- # from the generated hash.
150
- def serialize
151
- each_with_object Hash.new do |child, hash|
152
- hash[child.key] = child.serialize
153
- end
154
- end
155
-
156
- # Iterates through the children of the current namespace, which could be `Namespace` or `Field`
157
- # objects.
158
- def each(&)
159
- @children.values.each(&)
160
- end
161
-
162
- # Assigns a hash to the current namespace and children namespace.
163
- def assign(hash)
164
- each do |child|
165
- child.assign hash[child.key]
166
- end
167
- self
168
- end
169
-
170
- # Creates a root Namespace, which is essentially a form.
171
- def self.root(*, **, &)
172
- new(*, parent: nil, **, &)
173
- end
174
-
175
- protected
176
-
177
- # Calls the corresponding method on the object for the `key` name, if it exists. For example
178
- # if the `key` is `email` on `User`, this method would call `User#email` if the method is
179
- # present.
180
- #
181
- # This method could be overwritten if the mapping between the `@object` and `key` name is not
182
- # a method call. For example, a `Hash` would be accessed via `user[:email]` instead of `user.send(:email)`
183
- def object_for(key:)
184
- @object.send(key) if @object.respond_to? key
185
- end
186
-
187
- private
188
-
189
- # Checks if the child exists. If it does then it returns that. If it doesn't, it will
190
- # build the child.
191
- def create_child(key, child_class, **kwargs, &block)
192
- @children.fetch(key) { @children[key] = child_class.new(key, parent: self, **kwargs, &block) }
193
- end
194
- end
195
-
196
- class Field < Node
197
- attr_reader :dom
198
-
199
- def initialize(key, parent:, object: nil, value: nil)
200
- super key, parent: parent
201
- @object = object
202
- @value = value
203
- @dom = DOM.new(field: self)
204
- end
205
-
206
- def value
207
- if @object and @object.respond_to? @key
208
- @object.send @key
209
- else
210
- @value
211
- end
212
- end
213
- alias :serialize :value
214
-
215
- def assign(value)
216
- if @object and @object.respond_to? "#{@key}="
217
- @object.send "#{@key}=", value
218
- else
219
- @value = value
220
- end
221
- end
222
- alias :value= :assign
223
-
224
- # Wraps a field that's an array of values with a bunch of fields
225
- # that are indexed with the array's index.
226
- def collection(&)
227
- @collection ||= FieldCollection.new(field: self, &)
228
- end
229
- end
230
-
231
- class FieldCollection
232
- include Enumerable
233
-
234
- def initialize(field:, &)
235
- @field = field
236
- @index = 0
237
- each(&) if block_given?
238
- end
239
-
240
- def each(&)
241
- values.each do |value|
242
- yield build_field(value: value)
243
- end
244
- end
245
-
246
- def field
247
- build_field
248
- end
249
-
250
- def values
251
- Array(@field.value)
252
- end
253
-
254
- private
255
-
256
- def build_field(**)
257
- @field.class.new(@index += 1, parent: @field, **)
258
- end
259
- end
260
-
261
- class NamespaceCollection < Node
262
- include Enumerable
263
-
264
- def initialize(key, parent:, &template)
265
- super(key, parent: parent)
266
- @template = template
267
- @namespaces = enumerate(parent_collection)
268
- end
269
-
270
- def serialize
271
- map(&:serialize)
272
- end
273
-
274
- def assign(array)
275
- # The problem with zip-ing the array is if I need to add new
276
- # elements to it and wrap it in the namespace.
277
- zip(array) do |namespace, hash|
278
- namespace.assign hash
279
- end
280
- end
281
-
282
- def each(&)
283
- @namespaces.each(&)
284
- end
285
-
286
- private
287
-
288
- def enumerate(enumerator)
289
- Enumerator.new do |y|
290
- enumerator.each.with_index do |object, key|
291
- y << build_namespace(key, object: object)
292
- end
293
- end
294
- end
295
-
296
- def build_namespace(index, **)
297
- parent.class.new(index, parent: self, **, &@template)
298
- end
299
-
300
- def parent_collection
301
- @parent.object.send @key
302
- end
303
- end
304
13
  end
305
14
 
306
15
  def Superform(...)