Skip to content

Has Many Relationship Tab (with reordering)

Cezary Kłos edited this page Jun 26, 2023 · 4 revisions

This guide will allow you to drag & drop to reorder your model's has_many associations.

We will be using jQuery Sortable for frontend and nested attributes to handle the backend.

Let's get started!

1. Add jQuery Sortable to your project

Download the plugin here: https://github.com/johnny/jquery-sortable. This guide has been tested with v0.9.13.

Adding the JS

Once downloaded, place the jquery-sortable.js file in app/assets/javascripts/.

Add the following to your custom.js.erb file:

//= require "jquery-sortable"

Trestle.init(function() {
  // Sortable table
  $(".sortable-table").sortable({
    containerSelector: 'table',
    itemPath: '> tbody',
    itemSelector: 'tr',
    handle: 'i.fa.fa-bars',
    placeholder: '<tr class="placeholder"><td colspan=99><em>Place item here...</em></td></tr>',
    onDrop: function ($item, container, _super) {
      $(container.el).find(".sortable-table-position-input").each(function(index, input) {
        $(input).val(index);
      })
      _super($item, container);
    }
  });
});

The code does 2 things here:

  1. It makes sure jQuery Sortable works properly with tables.
  2. It calculates the position of each row and saves it in a hidden field. We will use that field later to update the row's position.

Adding the CSS

We will not be using the the jQuery Sortable styling because our tables are already styled and we want to make the integration as seamless as possible.

However, we do need a few things for the drag and drop animation to look good. Place the following in your _custom.scss file:

body.dragging, body.dragging * {
  cursor: move !important;
}
// Trestle applies a background when your curser hovers table rows. We need to remove that, otherwise the drag and drop animation is jittery.
body.dragging table.sortable-table tr {
  background-color: #FFFFFF !important;
}
.dragged {
  position: absolute;
  top: 0;
  opacity: .5;
  z-index: 2000;
}
tr.placeholder td {
  height: 24px;
}

The frontend is now ready.

2. Preparing our models

Let's say we have a model called Project that has many Tasks. For everything to work correctly, we'll have to do a few things:

In Tasks

  1. Create an integer column called position in Tasks
  2. Add a scope called ordered that will order Tasks by position. Something like this: scope :ordered, -> { order(position: :asc) }

In Project

  1. Add accepts_nested_attributes_for :tasks. This will make sure that we can update the Task positions when updating the Project. Don't worry if this is confusing, it will make sense below.

3. Preparing our controllers

We'll need to authorise passing of our nested attributes. Open the projects_admin.rb and permit tasks_attributes params. Something like this:

  params do |params|
    params.require(:project).permit(
      :name,
      tasks_attributes: [
        :id,
        :description,
        :position
      ]
    )
  end

4. Add the sort column to our table

We need to add an additional column to our table. This column will do 2 things:

  1. Show a drag-and-drop icon
  2. Store the current row's position

To do this, create the file app/columns/sortable_column.rb and paste the following code:

class SortableColumn < Trestle::Table
  attr_reader :field, :options

  def initialize(field, options = {})
    @field, @options = field, options
  end

  def renderer(table, template)
    Renderer.new(self, table: table, template: template)
  end

  class Renderer < Trestle::Table::Column::Renderer
    include ActionView::Helpers::TagHelper
    include ActionView::Helpers::FormTagHelper
    include ActionView::Context

    def header
      ""
    end

    def content(instance)
      model = options[:collection][0]
      collection = options[:collection][1]
      safe_join [
        content_tag(:i, "", class: "fa fa-bars cursor-move"),
        hidden_field_tag("#{model}[#{collection}_attributes][#{instance.id}][id]", instance.id),
        hidden_field_tag("#{model}[#{collection}_attributes][#{instance.id}][#{@column.field.to_s}]", instance.send(@column.field), class: "sortable-table-position-input")
      ]
    end
  end
end

Now we need to register this class so that we can use it in our Trestle tables. Paste the following at the end of your trestle.rb file:

class Trestle::Table::Builder
  def sortable_column(field, options = {})
    table.columns << SortableColumn.new(field, options)
  end
end

Now we can add the sortable_column to our tables. Like so:

table collection: project.tasks.ordered, admin: :tasks, class: "sortable-table", autolink: false do
  sortable_column :position, collection: [:project, :tasks]
  column :description, link: true
  actions
end

A few things are happening here:

  • We are using project.tasks.ordered as a collection. This is to ensure that our tasks are always ordered by the position field.
  • We add the sortable-table CSS class to add drag and drop capabilities to our table.
  • We disable autolink in the table to prevent misclicks from going to the task show page.
  • We are using the sortable_column that we just created.
    • The first parameter is the name of the field that will hold the actual position.
    • The second parameter is used to define the relationship between Project and Task. The first part has to be singular and the second part has to be plural. Otherwise, it will not work properly.

5. Test it out!