Practical examples to master web forms in Rails
This example demonstrates how we can generate an infinite to-do list. accepts_nested_attributes is used as expected with this type of form, but a few unconventional patterns are also required as Rails conventions do not support lazy recursion. First, pay attention to the stimulus controller and how items are added using the same template regardless of the level of nesting. Both the parent_id and child_id need to be updated, so we cannot use a traditional `f.fields_for :tasks` pattern here. Strong parameters also need to permit all attributes, as we cannot recursively permit tasks_attributes indefinitely without hitting a StackOverflow error.
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 "tasks#new"
resources :tasks
end
# ==== DB SCHEMA ====
ActiveRecord::Base.establish_connection
ActiveRecord::Schema.define do
create_table :tasks, force: :cascade do |t|
t.string :title
t.datetime :completed_at
t.references :parent, null: true
end
end
# ==== MODELS ====
class Task < ActiveRecord::Base
belongs_to :parent, class_name: 'Task', optional: true
has_many :tasks, foreign_key: :parent_id, dependent: :destroy
accepts_nested_attributes_for :tasks, allow_destroy: true
validates :title, presence: true
def as_json
super(only: [:id, :title]).merge('tasks' => tasks.map(&:as_json))
end
end
# ==== CONTROLLERS ====
class TasksController < ActionController::Base
layout 'application'
def new
@task = Task.new
2.times { @task.tasks.build }
end
def create
@task = Task.new(task_params)
if @task.save
redirect_to @task
else
render :new
end
end
def show
@task = Task.find(params[:id])
end
def edit
@task = Task.find(params[:id])
end
def update
@task = Task.find(params[:id])
if @task.update(task_params)
redirect_to @task
else
render :edit
end
end
def task_params
params.require(:task).permit!
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"]
static values = {
parentId: { type: String, default: 'PARENT-ID' },
childId: { type: String, default: 'CHILD-ID' },
}
addNestedForm(event) {
event.preventDefault();
const target = event.target
var template = this.templateTarget.innerHTML
.replaceAll(new RegExp(`${this.childIdValue}`, "g"), this.newChildID(target))
.replaceAll(new RegExp(`${this.parentIdValue}`, "g"), this.newParentID(target))
target.closest('.task')
.querySelector('.tasks')
.insertAdjacentHTML("beforeend", template)
}
removeNestedForm(event) {
event.preventDefault();
var element = event.target.parentElement
element.style.display = 'none'
element.querySelector("input[name*='_destroy']").value = '1'
}
newParentID(element) {
return element
.closest('.task')
.querySelector('input[name*="id"]')
.name
.replaceAll('[id]', '')
}
newChildID(_element) {
return Date.now()
}
})
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; }
}
CSS
# ==== VIEWS ====
UniRails.register_view "tasks/new.html.erb", <<~HTML
<h1>New Tasks</h1>
<p class="errors"><%= @task.errors.full_messages.to_sentence %></p>
<%= render partial: 'form', locals: { task: @task } %>
HTML
UniRails.register_view "tasks/edit.html.erb", <<~HTML
<h1>Edit <%= @task.title %></h1>
<p class="errors"><%= @task.errors.full_messages.to_sentence %></p>
<%= render partial: 'form', locals: { task: @task } %>
HTML
UniRails.register_view "tasks/show.html.erb", <<~HTML
<h1><%= @task.title %></h1>
<p><%= link_to 'Edit task', edit_task_path(@task) %></p>
<%= render partial: 'tasks', locals: { tasks: @task.tasks } %>
HTML
UniRails.register_view "tasks/_tasks.html.erb", <<~HTML
<ul class="tasks">
<% tasks.each do |task| %>
<li><%= task.title %></li>
<%= render partial: 'tasks', locals: { tasks: task.tasks } %>
<% end %>
</ul>
HTML
UniRails.register_view "tasks/_form.html.erb", <<~HTML
<%= form_with model: task do |f| %>
<div class="task" data-controller="nested-attributes">
<%= render 'fields', f: f, root: true, show_tasks: true %>
<template data-nested-attributes-target="template">
<%= fields_for 'PARENT-ID[tasks_attributes]', Task.new, index: 'CHILD-ID' do |ff| %>
<div class="task">
<%= render 'fields', f: ff, root: false, show_tasks: false %>
</div>
<% end %>
</template>
</div>
<%= f.submit %>
<% end %>
HTML
UniRails.register_view "tasks/_fields.html.erb", <<~HTML
<%= f.hidden_field :id %>
<%= f.hidden_field :_destroy unless root %>
<%= f.label :title %>
<%= f.text_field :title %>
<%= link_to 'Add', '#', data: { action: "nested-attributes#addNestedForm" } %>
<%= link_to 'remove', '#', data: { action: 'nested-attributes#removeNestedForm' } unless root %>
<div class="tasks" style="margin-left:1rem;">
<% if show_tasks %>
<%= f.fields_for :tasks do |ff| %>
<div class="task">
<%= render 'fields', f: ff, root: false, show_tasks: show_tasks %>
</div>
<% end %>
<% end %>
</div>
HTML
UniRails.run(Port: 3000)