Railsamples

Practical examples to master web forms in Rails

Nested Forms - Infinite nested levels

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)