Adding edit, delete and reposition for nested forms in Rails with Stimulus

In a previous article, I explored building nested forms with Stimulus. But what about when you need to edit existing questions, remove ones you no longer need or reorganize them? Let’s extend that foundation by adding: editing, deleting and repositioning questions using drag-and-drop.

This article builds directly on the previous setup, so make sure you have that in place before continuing (check out the repo for the full code base). The reposition logic is inspired by this article to create a Kanban board.

First, update the migration to include a unique index:

class AddPositionToQuestions < ActiveRecord::Migration[8.1]
  def change
    add_column :questions, :position, :integer, null: false
    add_index :questions, [:survey_id, :position], unique: true
  end
end

I like the positioning gem for this, make sure to set it up correctly.

Editing questions is really just vanilla Rails stuff, update the SurveysController with edit and update actions. It will save the question’s content alongside the survey models (I recently wrote how you can do this without using accepts_nested_attributes_for).

Now the more interesting part is the logic to reposition questions. Let’s go over the related parts. Add a RepositionController that stores the new position after drag-and-drop.

class RepositionController < ApplicationController
  def update
    resources.each_with_index do |resource, index|
      resource.update!(position: params[:new_position].to_i + index)
    end
  end

  private

  def resources
    resource_class.where(id: Array(params[:ids]))
  end

  def resource_class
    request.path.split("/")[1].singularize.classify.constantize
  end
end

Next is to updates your routes to include the new action:

Rails.application.routes.draw do
+  resources :questions, only: %w[destroy] do
+    collection do
+      patch :reposition, controller: "reposition", action: "update"
+    end
+  end
end

Update the nested-fields controller to support question removal.

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
+  remove(event) {
+    const field = event.target.closest("[data-sortable-id-value]");
+    const destroyInput = field.querySelector('input[name*="_destroy"]');

+    if (destroyInput) {
+      destroyInput.value = "1";

+      field.hidden = true;
+    } else {
+      field.remove();
+    }
+  }
}

The remove() method checks if there’s a _destroy hidden field (for existing records). If so, it sets the value to “1” and hides the field. For new records without this field, it simply removes the element from the DOM.

Make JavaScript your second favorite language
Published by Rails Designer. Buy it today.

Sortable Stimulus controller

Create a new Stimulus controller (this is mostly copied verbatim from the Kanban board article I mentioned) for drag-and-drop functionality:

// app/javascript/controllers/sortable_controller.js
import { Controller } from "@hotwired/stimulus"
import { Sortable } from "sortablejs"
import { patch } from "@rails/request.js"

export default class extends Controller {
  static values = { endpoint: String };

  connect() {
    Sortable.create(this.element, {
      group: "questions",
      animation: 150,
      easing: "cubic-bezier(1, 0, 0, 1)",
      ghostClass: "opacity-50",
      selectedClass: "selected",
      onEnd: (event) => this.#updatePosition(event)
    });
  }

  async #updatePosition(event) {
    const items = event.items?.length > 0 ? event.items : [event.item];
    const ids = items.map(item => item.dataset.sortableIdValue);

    await patch(this.endpointValue, {
      body: JSON.stringify({
        ids: ids,
        new_position: event.newIndex + 1
      })
    });
  }
}

This controller uses SortableJS and @rails/request.js to enable drag-and-drop.

Update the edit view

Create the edit view (app/views/surveys/edit.html.erb):

<h1>Edit Survey</h1>

<%= form_with model: @survey, data: {controller: "nested-fields"} do |form| %>
  <div>
    <%= form.label :name %>

    <%= form.text_field :name %>
  </div>

  <ul data-nested-fields-target="fields" data-controller="sortable" data-sortable-endpoint-value="<%= reposition_questions_path %>">
    <%= form.fields_for :questions do |question_form| %>
      <li data-sortable-id-value="<%= question_form.object.id %>">
        <%= question_form.hidden_field :_destroy %>

        <%= question_form.label :content, "Question" %>
        <%= question_form.text_area :content %>

        <%= button_tag "Remove", type: :button, data: { action: "nested-fields#remove" } %>
      </li>
    <% end %>
  </ul>

  <div>
    <!---->
  </div>
<% end %>

Key points in this view:

  • The <ul> has both nested-fields and sortable controllers
  • Each <li> has data-sortable-id-value for drag-and-drop tracking
  • The _destroy hidden field marks questions for deletion
  • The template includes the _destroy field for new questions too

And there you have it. A complete nested forms solutions for Rails with Stimulus that includes adding new questions, deleting and reposition them. From here it is straight-forward to add nested answers too!

Product-minded Rails notes

Once a month: straightforward notes on improving UX in Rails—what to simplify, what to measure, and UI/frontend changes that move real usage.

Over to you…

What did you like about this article? Learned something knew? Found something is missing or even broken? 🫣 Let me (and others) know!

Comments are powered by Chirp Form

Want to read me more?