The Form Object is a common pattern in Rails when our form becomes complex. But the tutorial in network's example usually incapable of Rails' form helper.
And I start thinking about is it possible to support form helpers without too many changes?
Common Form Object Implementation
To improve our form object, we have to know about the original version we are using.
class RegistrationForm
include ActiveModel::Model
include ActiveModel::Validations
attr_accessor :email, :password, :password_confirmation
def initialize(user, params = {})
@user = user
super params
end
# ...
def attributes
{
email: @email,
password: @password
}
end
def save
return unless valid?
@user.assign_attributes(attributes)
@user.save
end
end
This is a common form object you can find in the network, it gives us model-like behavior which can use it in the controller without too many changes.
But in the view, our form helper has to set method and URL at any time.
<%= form_for @form, method: :post, url: users_path do |f| %>
<% # ... %>
<% end %>
The Form Helper
To improve the form object, I starting review the source code of form helper.
In the action_view/helpers/form_helper.rb#L440, the ActiveView will try to apply_form_for_options!
if we give a object for it.
apply_form_for_options!(record, object, options)
In the apply_form_for_options!
method, we can find it set the method
and url
.
action, method = object.respond_to?(:persisted?) && object.persisted? ? [:edit, :patch] : [:new, :post]
# ...
options[:url] ||= if options.key?(:format)
polymorphic_path(record, format: options.delete(:format))
else
polymorphic_path(record, {})
end
That means if our form object can provide the same interface to the form helper, it will correctly configure the form's method and URL without extra work.
The persisted?
When the form helper decides to use POST
to create a new object or use PUT
to update an existing object. It depends on the model's persisted?
method.
That means we can add persisted?
method to our form object to make form helper can detect it.
class BaseForm
# ...
def initialize(record, params = {})
@record = record
super params
end
def persisted?
@record && @record.persisted?
end
end
But there has another better way to implement it, we can use delegate
which provided by ActiveSupport.
class BaseForm
# ...
delegate :persisted?, to: :@record, allow_nil: true
def initialize(record, params = {})
@record = record
super params
end
end
The to_param and model_name
The URL is generated by polymorphic_path
and it uses model_name
and to_param
to generate the path.
We can try it in the Rails console:
> app.polymorphic_path(User.new)
=> "/users"
> app.polymorphic_path(User.last)
=> "/users/1234"
And when we add delegate model_name
and to_param
to our form object, we can get the same result.
delegate :persisted?, :model_name, :to_param, to: :@record, allow_nil: true
And check it again:
> app.polymorphic_path(RegistrationForm.new(User.new))
=> "/users"
> app.polymorphic_path(RegistrationForm.new(User.last))
=> "/users/1234"
For now, we have the same interface as the model.
Load Attributes
Since we can let form helper work correctly but we still cannot load the data when we edit an existing model.
To resolve it, we can adjust our initialize method to retrieve the necessary fields.
class RegistrationForm < BaseForm
def initialize(record, params = {})
attributes = record.slice(:email, :password).merge(params)
super record, params
end
end
Another way is to use Attribute API to support it, but we have to exactly define each attribute in our form object.
class BaseForm
# ...
include ActiveModel::Attributes
def initialize(record, params = {})
@record = record
attributes = record.attributes.slice(*.self.class.attribute_names)
super attributes.merge(params)
end
end
# app/forms/registration_form.rb
class RegistrationForm < BaseForm
attribute :email, :string
end
But we have to take care the
params
hash, the model return attributes is{"name" => "Joy"}
but we use{name: "Joy"}
we will get the hash mixed string and symbol keys{"name" => "Joy", name: "Joy"}
and didn't set the attribute to our form object.
Future Improve
In the current version, we have to pass the model instance to the form object. Maybe we can add some DSL to auto-create it.
# Option 1
class RegistrationForm < BaseForm
model_class 'User'
attribute :name
end
# Option 2
class RegistrationForm < BaseForm[User]
attribute :name
end
But we also need to consider this way may not a good idea in some complex system.
For example, we had load the User
from a controller or other object. But we cannot pass it to the form object. That means our form object usually loads the object when we access it.
If we have nested form it will cause the N+1 query in this case.
This is another topic when we use the form object or service object to refactor our code. We may reduce the duplicate code but cause our system to slow down or new hidden bug we didn't know about it.
Conclusion
I didn't have many experiences to use the form object. But I think it is a common case when we build an application.
This version form object still has a lot of limitation and I didn't consider all possible use cases.
I will try to improve it in my future works and keep it as a simple object if possible. I believe we didn't always need to build a complex behavior and add a gem to resolve some simple things.
Top comments (0)