Practical examples to master web forms in Rails
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)