Railsamples

Practical examples to master web forms in Rails

Nested Forms - has many association

This example demonstrates how to create a nested form with a 'has many' association. Pay attention to the Stimulus controller used and how the form fields are reused. Note also how the fields_for are defined on both the form and the template.

ENV["SECRET_KEY_BASE"] = "1212312313"
ENV["DATABASE_URL"] = "sqlite3:///#{__dir__}/database.sqlite"

require "bundler/inline"

gemfile do
  source "https://www.rubygems.org"
  gem "uni_rails", "0.4.1"
  gem "sqlite3", "~> 1.7"
  gem "byebug"
end

require "uni_rails"
require "sqlite3"
require "byebug"

#  ==== ROUTES ====

UniRails::App.routes.append do
  root "accounts#new"
  resources :accounts
end

#  ==== DB SCHEMA ====

ActiveRecord::Base.establish_connection
ActiveRecord::Schema.define do
  create_table :accounts, force: :cascade do |t|
    t.string :name, null: false
  end

  create_table :transactions, force: :cascade do |t|
    t.references :account
    t.string :category
    t.decimal :value, null: false
  end
end

#  ==== MODELS ====

class Account < ActiveRecord::Base
  has_many :transactions
  accepts_nested_attributes_for :transactions, allow_destroy: true

  validates :name, presence: true

  def transactions_total
    transactions.sum(&:value)
  end
end

class Transaction < ActiveRecord::Base
  CATEGORIES = %w[housing food bills transportation other]

  belongs_to :account

  validates :value, presence: true, numericality: true

  before_validation { self.category = self.category.presence || 'other' }
end

#  ==== CONTROLLERS ====

class AccountsController < ActionController::Base
  layout 'application'

  def new
    @account = Account.new transactions: [
      Transaction.new(value: 100, category: 'food'),
      Transaction.new(value: 100, category: 'housing'),
      Transaction.new(value: 100, category: 'transportation'),
      Transaction.new(value: 100, category: 'bills'),
      Transaction.new(value: 100, category: 'other'),
    ]
  end

  def create
    if (@account = Account.new(account_params)).save
      redirect_to @account
    else
      render :new
    end
  end

  def show
    @account = Account.find(params[:id])
  end

  def edit
    @account = Account.find(params[:id])
  end

  def update
    if (@account = Account.find(params[:id])).update(account_params)
      redirect_to @account
    else
      render :edit
    end
  end

  private

  def account_params
    params
      .require(:account)
      .permit(:name, transactions_attributes: [:_destroy, :id, :category, :value])
  end
end

#  ==== IMPORT MAPS ====

UniRails.import_maps(
  'stimulus' => 'https://unpkg.com/@hotwired/stimulus/dist/stimulus.js'
)


#  ==== JAVASCRTIP ====

UniRails.javascript <<~JAVASCRIPT

  import { Application, Controller } from "stimulus"
  window.Stimulus = Application.start()

  Stimulus.register("nested-attributes", class extends Controller {
    static targets = ["template", "container"]

    addNestedForm(event) {
      event.preventDefault();

      const uniqueKey = Date.now()
      this.containerTarget.insertAdjacentHTML(
        "beforeend",
        this.templateTarget.innerHTML.replaceAll(/X-ID/g, uniqueKey)
      )
    }

    removeNestedForm(event) {
      event.preventDefault();

      var element = event.target.parentElement
      element.style.display = 'none'
      element.querySelector("input[name*='_destroy']").value = '1'
    }
  })

JAVASCRIPT


#  ==== CSS ====

UniRails.css <<~CSS

  html { background-color:#EEE; }
  body { width:500px; height:700px; margin:auto;
    background-color:white; padding:1rem;
  }
  fieldset { width: fit-content; margin-top: 1rem; }
  table, th, td { border: 1px solid; }
  th, td { padding: 0.5rem; }
  form {
    .errors { color: red; }
    label { display: block; }
    input[type="submit"] { display: block; margin-top:1rem;  }
    .field_with_errors { color:red;  display:inline; }
    .transaction { display: flex; margin-bottom: 0.5rem;
      label, input, select { margin-right: 1rem; }
    }
  }
CSS


#  ==== VIEWS ====

UniRails.register_view "accounts/new.html.erb", <<~HTML

  <h1>New Account</h1>
  <%= render 'form', account: @account %>
HTML


UniRails.register_view "accounts/edit.html.erb", <<~HTML

  <h1>Edit Account <%= @account.name %></h1>
  <%= render 'form', account: @account %>
HTML


UniRails.register_view "accounts/show.html.erb", <<~HTML

  <h1><%= @account.name %> details</h1>
  <p><%= link_to 'Edit account', edit_account_path(@account) %></p>

  <table id="transactions_totals">
    <thead>
      <th>Category</th>
      <th>Total</th>
    </thead>
    <tbody>
      <% @account.transactions.group_by(&:category).each do |category, transactions| %>
        <tr>
          <td><%= category.upcase %></td>
          <td><%= number_to_currency transactions.sum(&:value) %></td>
        </tr>
      <% end %>
      <tr>
        <td><strong>Total</strong></td>
        <td><%= number_to_currency @account.transactions_total %></td>
      </tr>
    </tbody>
  </table>
HTML


UniRails.register_view "accounts/_form.html.erb", <<~HTML

  <%= form_with model: account do |f| %>
    <p class="errors"><%= account.errors.full_messages.to_sentence %></p>
    <%= f.label :name %>
    <%= f.text_field :name %>

    <fieldset data-controller="nested-attributes">
      <legend>Transactions</legend>
      <div id="transactions" data-nested-attributes-target="container">
        <%= f.fields_for :transactions, account.transactions do |ff| %>
          <%= render 'transaction_fields', f: ff do %>
            <%= link_to 'remove', '#', data: { action: 'nested-attributes#removeNestedForm' } %>
          <% end %>
        <% end %>
      </div>

      <div><%= link_to 'Add transaction', '#', data: { action: "nested-attributes#addNestedForm" } %> </div>

      <template data-nested-attributes-target="template">
        <%= f.fields_for :transactions, Transaction.new, child_index: 'X-ID' do |ff| %>
          <%= render 'transaction_fields', f: ff do %>
            <%= link_to 'remove', '#', data: { action: 'nested-attributes#removeNestedForm' } %>
          <% end %>
        <% end %>
      </template>
    </fieldset>

    <%= f.submit %>
  <% end %>
HTML


UniRails.register_view "accounts/_transaction_fields.html.erb", <<~HTML

  <div class="transaction" style="<%= 'display:none;' if f.object.marked_for_destruction? %>">
    <%= f.hidden_field :id %>
    <%= f.hidden_field :_destroy %>
    <%= f.label :value %>
    <%= f.number_field :value, step: 0.01 %>
    <%= f.label :category %>
    <%= f.collection_select :category, Transaction::CATEGORIES, :to_s, :to_s, include_blank: true %>
    <%= yield %>
  </div>
HTML


UniRails.run(Port: 3000)