Most websites have a set of pages that need to be arranged as a tree, allowing users to drill down into more detailed pages.

To manage that in the admin area, it's nice to be able to drag and drop the pages into the tree.  Such a fancy feature is pretty hard to build though right?  Not with RubyOnRails and a little bit of simple Stimulus.js javascript sprinkled on top.

Here is what we are aiming for.

hierarchical drag drop2

In the example above, you can see the user adds a "Web Development" page to the menu using the search facility, then drags the page into position as a sub page of page "Websites", then drags "Websites" up a bit. Because Websites are important : )

To get started,  add sortablejs

yarn add sortablejs   

Now add ancestry and acts_as_list to your gem file:

gem 'ancestry'
gem 'acts_as_list

Ancestry gem handles all the logic for the tree structure of the menu while acts_as_list manages the sort order.

In this example, we have two models to create the menu:  Menu and Menuitem. Any model can be attached, but in this example we attach Page. In future, more models like Article or Blog can easily be configured via the polymorphic association.


class Menu < ApplicationRecord
  validates :name, presence: true
  has_many :menuitems, -> { order(position: :asc) }
end

class Menuitem < ApplicationRecord
  # validates :name, presence: true
  acts_as_list scope: [:ancestry]
  has_ancestry cache_depth: true, counter_cache: true
  belongs_to :menu
  belongs_to :menuitemable, polymorphic: true, optional: true

  validates :name, presence: true, unless: :regular_link?

  def regular_link?
    menuitemable.present?
  end

  def to_s
      if name.present?
        name
      elsif menuitemable.present?
        menuitemable.to_s
      else
        ""
      end
  end

  def the_link
    if menuitemable
      menuitemable.link
    else
      link
    end
  end
end

To display the menu, add a div and connect it to the JS controller. Then call a helper to loop through the menuitems and display them as a nested tree. menus/show.html.erb:

<div  class="card-body nested"
        data-controller="dragger"
        data-dragger-url="/admin/menus/<%= @menu.id %>/menuitems/:id/move">
    <%=  nested_items(@menu.menuitems.arrange(:order => :position)) %>
  </div>

In the nested item, we loop recursively around the menu items, rendering each one using its partial.  Honestly this is the part I found most difficult.  application_helper.rb:


def nested_items(items)
  items.map do |item, sub_items|
    content_tag(:div,
                (render(item) +  content_tag(
                                          :div,
                                          nested_items(sub_items),
                                          class: 'nested',
                                          'data-id': item.id,
                                          )).html_safe,
                class: "list-group-item ",
                'data-id': item.id,
              )
  end.join.html_safe
  

And here is that partial,  _menuitem.html.rb:

<div class="menuitem">
    <div class="row">
      <div class="col-5 menitem-name" >
          <i class="bi bi-grip-vertical"></i><%= menuitem.to_s  %>
      </div>
      <div class="col-4 actions">
          <a class='subtle' href="<%= menuitem.the_link %>" target="_blank"><%= menuitem.the_link %></a>
      </div>
      <div class="col-2 actions">
        <% if menuitem.menuitemable %>
          <%= link_to "#{menuitem.menuitemable.class.to_s.downcase}:#{menuitem.menuitemable.title}", edit_polymorphic_path([:admin, menuitem.menuitemable]), class: 'subtle' %>
        <% end %>
      </div>
      <div class="col-1 text-right actions">
        <%= link_to "<i class='bi-pencil'></i>".html_safe, edit_admin_menu_menuitem_path(menuitem.menu_id, menuitem.id) %>
        <%= link_to "<  i class='bi-trash'></i>".html_safe, admin_menu_menuitem_path(menuitem.menu_id, menuitem.id), data: {
                            turbo_method: :delete,
                            turbo_confirm: "Deletes any sub items. Are you sure?"
                          } %>
      </div>
    </div>
</div>

 

To control the drag behaviour, we set up a StimulusJS controller. This configures each menuitem as a Sortable and sets up the end method that sends a message to the Rails controller when a drag event happens. You can play with the sortable options to  dragger_controller.js:


import { Controller } from "@hotwired/stimulus"
import Sortable from "sortablejs"
import Rails from "@rails/ujs"

export default class extends Controller {
  connect() {
    var nestedSortables = [].slice.call(document.querySelectorAll('.nested'));
    // Loop through each nested sortable element
    for (var i = 0; i < nestedSortables.length; i++) {
    	new Sortable(nestedSortables[i], {
    		group: 'nested',
    		animation: 150,
    		fallbackOnBody: true,
    		swapThreshold: 0.65,
        onEnd: this.end.bind(this),
    	});
    }
  }

  end(event) {
    let id = event.item.dataset.id
    let parent_node = event.item.closest('.nested')
    let data = new FormData()
    data.append("position", event.newIndex + 1)
    data.append("ancestry", parent_node.dataset.id)
    Rails.ajax({
      url: this.data.get("url").replace(":id", id),
      type: 'PATCH',
      data: data
    })
  }
}

Now listen out for the items moving and update the ancestor & position in /controllers/admin/menuitems_controller.rb:


...
  def move
    @menuitem = Menuitem.find(params[:id])
    if params[:ancestry] == 'undefined'
      @menuitem.update_columns(ancestry:  nil)
    else
      @menuitem.update_columns(ancestry:  params[:ancestry])
    end
    @menuitem.insert_at(params[:position].to_i)
    head :ok
  end
...

And that's it. This example uses simple rails features, Ancestry and Acts As List - standard Gems that most rails developers will be familiar with and SortableJS combined with StimulusJS in a way that is easy to understand and maintain.