Railsamples

Practical examples to master web forms in Rails

Nested Attributes - 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)