Benito Serna Tips and tools for Ruby on Rails developers

Using a form object to create more than one record

October 14, 2020, updated on December 21, 2021

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.

“Personal finances” demo app

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…

Play with the code

The code of the app is on github.com/bhserna/form object_example, you can clone it and play with it.

Specify the fields of the form

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.

Display errors

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.

Populate the “target account” options

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

Process the received params

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!

Constructing the transfer_params

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

Normalizing values

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

Saving the form

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

Conclusion

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.

Related articles

Subscribe to get future posts via email

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.