Railsamples

Practical examples to master web forms in Rails

Nested Forms - Two nested levels

This example shows how we can create a form with two levels of nesting attributes. Pay attention to the form fields and the definition of the stimulus controller "nested-attributes".

# RUN THE SERVER WITH "ruby meal_plans.rb" and access http://localhost:3000


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

require "bundler/inline"

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

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

#  ==== ROUTES ====

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

#  ==== DB SCHEMA ====

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

  create_table :days, force: :cascade do |t|
    t.integer :number
    t.references :meal_plan
  end

  create_table :meals, force: :cascade do |t|
    t.references :day
    t.references :recipe
  end

  create_table :recipes, force: :cascade do |t|
    t.string :name
  end
end

#  ==== MODELS ====

class MealPlan < ActiveRecord::Base
  has_many :days
  has_many :meals, through: :days

  accepts_nested_attributes_for :days, allow_destroy: true

  validates :name, presence: true
end

class Day < ActiveRecord::Base
  belongs_to :meal_plan
  has_many :meals

  accepts_nested_attributes_for :meals, allow_destroy: true

  validates :number, presence: true
end

class Meal < ActiveRecord::Base
  belongs_to :day
  belongs_to :recipe

  delegate :name, to: :recipe, prefix: true
end

class Recipe < ActiveRecord::Base
  validates :name, presence: true
end

#  ==== SEEDS====

Recipe.create!(name: 'chicken/broccoli/rice')
Recipe.create!(name: 'salade nicoise')
Recipe.create!(name: 'pizza margerita')
Recipe.create!(name: 'raclette')

#  ==== CONTROLLERS ====

class MealPlansController < ActionController::Base
  layout 'application'

  def new
    @meal_plan = MealPlan.new
    day = @meal_plan.days.build
    day.meals.build
  end

  def create
    @meal_plan = MealPlan.new(meal_plan_params)
    if @meal_plan.save
      redirect_to @meal_plan
    else
      render :new
    end
  end

  def show
    @meal_plan = MealPlan.find(params[:id])
  end

  def edit
    @meal_plan = MealPlan.find(params[:id])
  end

  def update
    @meal_plan = MealPlan.find(params[:id])
    if @meal_plan.update(meal_plan_params)
      redirect_to @meal_plan
    else
      render :edit
    end

  end

  private def meal_plan_params
    params.require(:meal_plan).permit(
      :name,
      days_attributes: [
        :id,
        :number,
        :_destroy,
        meals_attributes: [:id, :day_id, :recipe_id, :_destroy]
      ]
    )
  end
end

#  ==== IMPORT MAPS ====

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

#  ==== JAVASCRIPT ====

UniRails.javascript <<~JAVASCRIPT

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

  Stimulus.register("nested-attributes", class extends Controller {
    static targets = ["template", "container"]
    static values = {
      id: { type: String, default: 'X-ID' }
    }

    addNestedForm(event) {
      event.preventDefault();

      const uniqueKey = Date.now()
      this.containerTarget.insertAdjacentHTML(
        "beforeend",
        this.templateTarget.innerHTML.replaceAll(this._idToReplace, uniqueKey)
      )
    }

    removeNestedForm(event) {
      event.preventDefault();

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

    get _idToReplace() {
      return new RegExp(`${this.idValue}`, "g");
    }
  })

JAVASCRIPT


#  ==== CSS ====

UniRails.css <<~CSS

  html { background-color:#EEE; }
  body { width:500px; height:700px; margin:auto;
    background-color:white; padding:1rem;
  }
  .errors { color: red; }
  form {
    label { display: block; }
    input[type="submit"] { display: block; margin-top:1rem;  }
    .field_with_errors { color:red;  display:inline; }
    legend { font-weight:bold; }
    fieldset.meals { border: unset; margin-top:1rem; padding: 0 0 0 .5rem;
      select { margin:0.25rem 0; }
    }
  }
CSS


#  ==== VIEWS ====

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

  <h1><%= @meal_plan.name %></h1>
  <p><%= link_to 'Edit meal plan', edit_meal_plan_path(@meal_plan) %></p>

  <% @meal_plan.days.includes(meals: :recipe).each do |day| %>
    <p><strong>Day Number <%= day.number %></strong></p>
    <ol>
      <% day.meals.each do |meal|%>
        <li><%= meal.recipe_name %></li>
      <% end %>
    </ol>
  <% end %>
HTML


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

  <h1>Edit <%= @meal_plan.name %></h1>
  <%= render partial: 'form', locals: { meal_plan: @meal_plan } %>
HTML


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

  <h1>New meal plan</h1>
  <%= render partial: 'form', locals: { meal_plan: @meal_plan } %>
HTML


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

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

    <div data-controller="nested-attributes" data-nested-attributes-id-value="X1-ID">
      <div id="days" data-nested-attributes-target="container">
        <%= f.fields_for :days do |ff| %>
          <%= render 'day_fields', f: ff %>
        <% end %>
      </div>

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

      <template data-nested-attributes-target="template">
        <%= f.fields_for :days, Day.new(meals: [Meal.new]), child_index: 'X1-ID' do |ff| %>
          <%= render 'day_fields', f: ff %>
        <% end %>
      </template>
    </div>

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


UniRails.register_view "meal_plans/_day_fields.html.erb", <<~HTML

  <fieldset class="day" style="<%= 'display:none;' if f.object.marked_for_destruction? %>">
    <legend>Day</legend>
    <%= f.hidden_field :_destroy %>
    <%= f.hidden_field :id %>
    <%= f.label :number %>
    <%= f.number_field :number, step: 1 %>
    <%= link_to 'remove day', '#', data: { action: 'nested-attributes#removeNestedForm' } %>
    <fieldset class="meals" data-controller="nested-attributes" data-nested-attributes-id-value="X2-ID">
      <legend>Meals</legend>
      <div class="meals" data-nested-attributes-target="container">
        <%= f.fields_for :meals do |ff| %>
          <%= render 'meal_fields', f: ff %>
        <% end %>
      </div>

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

      <template data-nested-attributes-target="template">
        <%= f.fields_for :meals, Meal.new, child_index: 'X2-ID' do |ff| %>
          <%= render 'meal_fields', f: ff %>
        <% end %>
      </template>
    </fieldset>
  </fieldset>
HTML


UniRails.register_view "meal_plans/_meal_fields.html.erb", <<~HTML

  <div class="meal" style="<%= 'display:none;' if f.object.marked_for_destruction? %>">
    <%= f.hidden_field :id %>
    <%= f.hidden_field :_destroy %>
    <%= f.collection_select :recipe_id, Recipe.all, :id, :name %>
    <%= link_to 'remove meal', '#', data: { action: 'nested-attributes#removeNestedForm' } %>
  </div>
HTML


UniRails.run(Port: 3000)