Have you been stuck trying to build a form to save more than one record?
Rails form helpers are really easy to work with when you are working with an
ActiveRecord::Base
object…
But what do you do when you need to create a form to save multiple records?…
How do you specify the form_with
code and fields?… How do you populate the
form options?
One way of doing it is create a new object, what is sometimes called a “form object”.
Here I will try to explain you how you can use a form object to create more than one record using one form.
I have build a kind of “personal finances” app , to keep track of transactions in bank accounts.
It has two records, Account
and Transaction
, and one of the features of
the app is that it can register a transfers from one account to another.
The app provides a select for the target account and an input field for the amount, with that it will register two transactions, one “outflow” from the “source account” and one “inflow”, to the “target account”.
Like in the next video…
In this post I will show you how using a “form object”, can help you to…
The code of the app is on github.com/bhserna/form object_example, you can clone it and play with it.
If you go to the file of the transfer
object
you will see that it include ActiveModel::Model
and defines some “attribute accessors”…
class Transfer
include ActiveModel::Model
attr_accessor :date, :source_account_id, :target_account_id, :amount
validates_presence_of :date, :source_account_id, :target_account_id, :amount
#...
end
You don’t really need ActiveModel::Model
to build a “form object” but it will
help you to handle the attributes initialization and validations like in
ActiveRecord::Base
objects.
It initializes this object in the transfers controller…
class TransfersController < ApplicationController
before_action :set_account
def new
@transfer = Transfer.new(source_account_id: @account.id)
end
#...
end
And it uses it in the form…
= form_with model: @transfer, url: account_transfers_path(@account) do |f|
= f.hidden_field :source_account_id
%p
= f.label :date
%br
.date-inputs
= f.date_select :date, selected: Date.current
%p
= f.label :target_account_id
%br
= f.collection_select :target_account_id, @transfer.target_options, :id, :name
%p
= f.label :amount
%br
= f.number_field :amount
= errors_for @transfer, :amount
%p
= f.button
Here the form uses the instance of our Transfer
object to populate each field as with regular ActiveRecord::Base
objects.
If you see the
form
, you will see that, it uses an errors_for
helper…
%p
= f.label :amount
%br
= f.number_field :amount
= errors_for @transfer, :amount
It is defined in the application helper…
module ApplicationHelper
def errors_for(model, key)
tag.div(class: "errors") do
model.errors.messages_for(key).join(", ")
end
end
end
It uses the tag method from ActionView
and the errors object provided by ActiveModel::Model
in the Transfer
object.
If you see the
form
again, you will see that for the collection_select
it is using @transfer.target_options
.
= f.collection_select :target_account_id, @transfer.target_options, :id, :name
You can actually define this method wherever you want. Maybe the Account
model could also be a good place. But as I am using this object specifically to
handle transfers I think that maybe this could be a good place to put this
behavior.
class Transfer
#...
def target_options
Account.where.not(id: source_account_id)
end
end
On the TransfersController…
It initializes the Transfer
with the transfer_params
and then it calls @transfer.save
.
def create
@transfer = Transfer.new(transfer_params)
if @transfer.save
#...
else
#...
end
end
As you can see, It looks almost like an standard scaffold controller!
An ActiveRecord::Base
object will let us pass directly the params that our
date_select
returns, but an ActiveModel::Model
can’t do that (at least for now).
For that reason to build the transfer_params it first builds the date from the params and then it merges it to the permitted params.
def transfer_params
params.require(:transfer)
.permit(:source_account_id, :target_account_id, :amount)
.merge(date: date_from_params(:transfer))
end
You can find the implementation for the date_from_params
method on the ApplicationController.
It looks like this…
def date_from_params(key)
Date.new(*date_args_from_params(key))
end
def date_args_from_params(key)
%w(1 2 3).map { |e| params[key]["date(#{e}i)"].to_i }
end
On the initialization first it
transforms
the amount
from an string to an integer (I am not using cents, because it is
a demo, in real life you may want to use a money
object or BigDecimal
).
def amount=(value)
@amount = value.to_i if value.present?
end
The Transfer#save method then checks if it is valid and then makes the transfer
.
def save
if valid?
Transaction.transfer(
date: date,
source_account: Account.find(source_account_id),
target_account: Account.find(target_account_id),
amount: amount
)
end
end
Here, it using the valid?
method that will executes the defined validations, provided by ActiveModel::Model
.
The Transaction.transfer
method is implemented on Transaction
, just because
I think that it makes more scence there. But depending on the complexity of
your problem you can execute the creation of the records directly on the form
or delegate it to another object,
class Transaction < ApplicationRecord
#...
def self.transfer(date:, source_account:, target_account:, amount:)
transaction do
create(
date: date,
account: source_account,
target_name: target_account.name,
description: "Transfer to #{target_account.name}",
amount: -1 * amount
)
create(
date: date,
account: target_account,
target_name: source_account.name,
description: "Transfer from #{source_account.name}",
amount: amount
)
end
end
end
Cool I think that’s it!!
As you can see, you can use a form object when you need to create more than one record by using just one form to capture the data. It can help you to…
Remember that for now if your form uses date_select
or datetime_select
you will have to build the date from the params before passing it to your form object.
Here I try to share knowledge and fixes to common problems and struggles for ruby on rails developers, like How to fetch the latest-N-of-each record or How to test that an specific mail was sent or a Capybara cheatsheet. You can see more examples on Most recent posts or all posts.