Benito Serna
Ruby/Rails, TDD, Software...

You can create a model for that multi-record form

Are you 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 one ActiveRecord model… but what happen 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?

Well… The trick is to create a new object, but is not that obvious how to do it…

I will try to explain you how with an example…

A form for a “transfer”…

Imagine that you are building a personal finance app, and you have two models, Account and Transaction…

class Transaction < ApplicationRecord
end

class Account < ApplicationRecord
end

And you want to build a feature to transfer money between 2 accounts, by creating two “transactions”.

You want a form on the account’s page, with a field to select the target account and a field to capture the amount.

How do you specify the form_with code and fields?… How do you populate the form options?

An object to “model” the transfer form

Well… as I said earlier the trick is to create a new object…

If you want to create a transfer, this object could help you keep the values that you want to capture on the form, like the “source” account", “target account” and the “amount”.

It can also help you to compute the “target options”.

It could be a plain old ruby object (PORO), but you can include ActiveModel::Model to help you handle the attributes initialization and validations.

You can start with something like this…

class TransferForm
  include ActiveModel::Model
  attr_accessor :source_id, :target_id, :amount
end

You will need to instantiate this object… You can put it in the show action of the AccountsController.

class AccountsController < ApplicationController
  def show
    @account = Account.find(params[:id])
    @transfer_form = TransferForm.new(source_id: @account.id)
  end
end

And use it in the form html…

<%= form_with(model: @transfer_form, url: transfers_path, local: true) do |form| %>
  <%= form.hidden_field :source_id %>

  <div class="field">
    <%= form.label :target_id %>
    <%= form.text_field :target_id %>
  </div>

  <div class="field">
    <%= form.label :amount %>
    <%= form.number_field :amount %>
  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>

But you don’t want to receive the target id in a text field, you want to have a select with all the available accounts, but without the source account.

To do it you can add a method in the form object to handle that logic.

class TransferForm
  include ActiveModel::Model
  attr_accessor :source_id, :target_id, :amount

  def target_options
    Account.where.not(id: source_id)
  end
end

Nice!…

Let’s update the form html…

<%= form_with(model: @transfer_form, url: transfers_path, local: true) do |form| %>
  <%= form.hidden_field :source_id %>

  <div class="field">
    <%= form.label :target_id %>
    <%= form.collection_select :target_id, @transfer_form.target_options, :id, :name %>
  </div>

  <div class="field">
    <%= form.label :amount %>
    <%= form.number_field :amount %>
  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>

After that you can add a create action on a new TransfersController using the form object to receive the params and save the Transaction records.

class TransfersController < ApplicationController
  def create
    @transfer_form = TransferForm.new(params[:transfer_form])

    if @transfer_forrm.save
      # success…
    else
      # error…
    end
  end
end

Yeii!… It looks almost like an standard scaffold controller =)

Now you need to implement that TransferForm#save method, that will create the transactions for the transfer…

class TransferForm
  include ActiveModel::Model
  attr_accessor :source_id, :target_id, :amount

  def account_options
    Account.where.not(id: source_id)
  end

  def save
    Transaction.transaction do
      Transaction.create(
        account_id: source_id,
        description: "Transfer to ##{target_id}",
        amount: -1 * amount
      )

      Transaction.create(
        account_id: target_id,
        description: "Transfer from ##{source_id}",
        amount: amount
      )
    end
  end
end

Ups!… but first you need to transform the amount to a BigDecimal (maybe in real life you want to use a money object) with a custom accessor.

class TransferForm
  include ActiveModel::Model
  attr_reader :amount
  attr_accessor :source_id, :target_id

  def target_options
    Account.where.not(id: source_id)
  end

  def save
    Transaction.transaction do
      Transaction.create(
        account_id: source_id,
        description: "Transfer to ##{target_id}",
        amount: -1 * amount
      )

      Transaction.create(
        account_id: target_id,
        description: "Transfer from ##{source_id}",
        amount: amount
      )
    end
  end

  def amount=(value)
    if value.present?
      @amount = value.to_d
    end
  end
end

Hmmm… but there is a lot of Transaction there…

Maybe that logic should be in our Transaction object… lets put it there and just call the new method in TransferForm#save

class Transaction < ActiveRecord::Base
  def self.transfer(source_id, target_id, amount)
    transaction do
      create(
        account_id: source_id,
        description: "Transfer to ##{target_id}",
        amount: -1 * amount
      )

      create(
        account_id: target_id,
        description: "Transfer from ##{source_id}",
        amount: amount
      )
    end
  end
end
class TransferForm
  include ActiveModel::Model
  attr_reader :amount
  attr_accessor :source_id, :target_id

  def target_options
    Account.where.not(id: source_id)
  end

  def save
    Transaction.transfer(source_id, target_id, amount)
  end

  def amount=(value)
    if value.present?
      @amount = value.to_d
    end
  end
end

It looks a little cleaner =)… That is almost it!

Now you can add some validations. For example, let’s require that all fields are present to save the transfer…

class TransferForm
  include ActiveModel::Model
  attr_accessor :source_id, :target_id, :amount
  validates_presence_of :source_id, :target_id, :amount

  def account_options
    Account.where.not(id: source_id)
  end

  def save
    if valid?
      Transaction.transfer(source_id, target_id, amount)
    end
  end

  def amount=(value)
    if value.present?
      @amount = value.to_d
    end
  end
end

Cool I think that’s it!!

At this moment you have a form object that will help you implement that form, and if you need more validations or more behavior, you know how to do it =)

You have a new ability

Now you know that when you need to save or interact with more than one record and you don’t have a place to handle the required data and behavior, you can create a new object (form object) to handle it.