Form Helpers and Form Objects - Struggling to create correct form structure

Hi,

I’m implementing a basic personal finance application in Rails.

I’m struggling to implement a form array as part of a form. I want to use a form object, as the model does not match the form 1-to-1.

Effectively, a Transaction can have multiple Payments. A Payment must have a Category associated with it.

The new view I am adding allows for multiple Payments to be added to a Transaction at the same time.

The new view would look like this table, with the last row being a new row that can be added:

Date Description Payment Type Actions Gas Electricity Car Maintenance
2020-01-01 British Gas Bank Giro Credit (BGC) View Button 10.00
2020-01-02 Southern Electric Cheque (CHQ) View Button 20.00
2020-01-02 Suzuki Faster Payment Out (FPO) View Button 100.00
(date input) (input) (<select> list) Create Button (input field) (input field) (input field)

Then, for the form’s POST structure, I would like it to look like this:

{
  "date": "2020-01-02",
  "payment_type": "BGC",
  "description": "PAYMENT DESCRIPTION",
  "payments": [
    {
      "category_id": 2, // Electricity Payment
      "amount": "20.00"
    },
    {
      "category_id": 3, // Car Maintenance Payment
      "amount": "100.00"
    }
  ]
}

In order to facilitate this, I’m attempting to use form objects, as the forms themselves don’t map directly to the Transaction, Payment and Category models.

For my Rails objects, I’m attempting to use YAAF (though, if it can be attempted in “vanilla” Rails, I would appreciate any help also).

Here is the ERB making up the form:


      <tr>
        <!-- Editable section for each of the Categories. Creates 1 Transaction, and N Payments based on each category. -->
        <%= form_with model: @transaction_form, scope: :transaction, url: create_transaction_and_payments_bank_account_closures_path(@bank_account), method: 'post', local: true do |form| %>
          <td>
            <%= form.label :date %>
            <%= form.date_field :date, value: DateTime.now %>
          </td>

          <td>
            <%= form.label :description %>
            <%= form.text_field :description %>
          </td>

          <td>
            <%= form.label :payment_type %>
            <%= form.select :payment_type, Transaction.payment_types.keys.map { |w| ["#{w.titleize} (#{Transaction.payment_type_to_initial(w.to_sym)})", w]}, include_blank: true %>
          </td>

          <td>
            <%= form.hidden_field :direction, value: direction %>
          </td>

          <td>
            <%= form.submit 'Create' %>
          </td>


          <%= form.fields_for :payments do |payment_form| %>
          <td>
              <%= payment_form.hidden_field :category_id %>
              <%= payment_form.text_field :amount %>
          </td>
          <% end %>

        <% end %>
      </tr>

When this is submitted, however, it ends up creating a structure of:

{
  "direction" => "in",
  "description" => "DESCRIPTION",
  "date" => "2020-01-10",
  "payment_type" => "bank_giro_credit",
  "payments_attributes" => {
    "0" => { "amount" => "10.00" },
    "1" => { "amount" => "20.00" },
    "2" => { "amount" => "" },
    "3" => { "amount" => "" },
    "4" => { "amount" => "" },
    "5" => { "amount" => "" },
    "6" => { "amount" => "" },
    "7" => { "amount" => "" },
    "8" => { "amount" => "" },
    "9" => { "amount" => "" },
    "10" => { "amount" => "" }
  }
}

And here are the Ruby form objects:

class Closures::SimpleTransactionCreationForm < YAAF::Form
  attr_accessor :direction,
                :description,
                :date,
                :payment_type,
                :payments_attributes,
                :payments_params,
                :categories

  def initialize(args = {}, categories = [])
    super(args)

    @categories = categories
  end

  def payments
    if @payments
      return @payments
    end

    @payments = []
    @categories.each.with_index do |c, i|
      @payments.push(Closures::SimplePaymentCreationForm.new(
        payments_params&.dig(:payments_attributes, i.to_s),
        c
      ))
    end

    return @payments
  end
end
class Closures::SimplePaymentCreationForm < YAAF::Form
  attr_accessor :amount,
                :category_id,
                :category,
                :_category

  def initialize(attrs = {}, category)
    super(attrs)

    @_category = category
  end
end

I have a couple of questions:

  • With the built-in Rails form helpers (with form objects), is it possible to create the JSON structure as the POST request?
  • Am I instantiating each column correctly?
  • Do I need YAAF? Can this be achieved without libraries, or do I need to use a specific library to achieve this?

Any help would be greatly appreciated! I apologise if this post rambles for too long, but I’ve been struggling to find an answer online, and I’m lost as to what to do next. I’m relatively new to Rails, but I have lots of experience with other frameworks and how they achieve this.

Have you looked at the accepts_nested_attributes_for macro method yet? I wouldn’t say it’s tightly coupled with fields_for, but they are designed to work together. When you mix that into your parent model (and have set up the proper association with the child model), the whole business of making a nested form becomes almost trivial. Want to add N child records, like splitting a payment into multiple arbitrary categories? That’s the Rails way to do that.

Hi, thanks for the speedy response!

Yes, I’ve seen accepts_nested_attributes_for in other examples.

I think this is only used for ActiveRecord models though, since I’ve tried including it into my form object, and it doesn’t seem to work, usually throwing an exception.

Would it be better that I create separate ActiveRecord objects for my form instead, and upon saving that model structure, create and save the Transaction, Payments etc.?

If you want to use fields_for, I recommend you also use an ActiveRecord for each “part” of the puzzle. I am sure with some careful study of the source code, you could figure out what ActiveRecord injects into a real model when you invoke that, and either add those parts in using ActiveModel, or simply duplicate the mechanism in your own controller and PORO.

Rails has come a long way when it comes to making these parts composable, and I know I have seen some examples of Form Objects built using ActiveModel as a part of the puzzle.

But in my own work, I tend to just use the whole AR everywhere approach, even if it doesn’t seem to make sense to keep each of the individual pieces separately. From a logging and auditing standpoint, as well as simply getting to use the validation pieces, it’s often less work, even if it does mean adding another table or more.

Walter

1 Like

OK, thanks! I think it also makes sense - rather than copying everything into separate form object(s) / POROs, and duplicating validation, creation etc. by include-ing a lot of ActiveRecord modules, it makes sense just to directly use ActiveRecord objects instead. At least this way, it’ll work exactly the same as “regular” ActiveRecord models.

Thanks for your help! And hope you are enjoying your Sunday! :smiley:

If you aren’t married to that specific JSON format, you could achieve this with Active Model:

class Transaction
  include ActiveModel::Model
  attr_accessor :payments, :payment_type, :description, :date
  def to_param
    # if this object wraps an Active Record, this should
    # return that record's ID, otherwise nil
  end

  def persisted?
    # if this object wraps an existing Active Record, this
    # should return true.  Otherwise, false.
  end

end

class Payment
  include ActiveModel::Model
  attr_accessor :category, :amount
end

Instances of these two classes will behave exactly as an active record one inside your form. You can then use form_with model: @transaction and all the form helpers should generate the proper markup. You would use fields_for for the payments, and then your controller should get this structure:

{
  transaction: {
    date: ...,
    description: ...,
    payments: [
      {
        amount: ...,
        category: ...,
      },
      # etc
    ]
  }
}