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.
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.
Leave a Reply
Comments
alxg:
Will I ran your application, sorting is not working as expected. Look at my fix, as I said earlier, it solves the problem. https://github.com/gordienk...
Will:
Brandon Christman + @alxg - I've made my demo / experiment repo public so you guys can play around with the code. Enjoy!
alxg:
Brandon Christman Here is a solution that works exactly
https://github.com/gordienk...
https://github.com/gordienk...
alxg:
none
alxg:
Will Most likely you gave a link to a private repository. If it is not difficult for you, please send the contents of the file so that we can look. Thanks in advance!
Brandon Christman:
gotcha. I tried your link below but it sent me to a 404, sadly.
I'm not sure what's missing in the config. Between the tutorial above, my attempt and alxg's we all seem to be seeing the same thing, where positions are being overwritten?
Will:
It's much more likely to be a bug with the demo, It's actually ancestry gem that manages the nesting, not just acts_as_list so it's likely to to be the configuration of those two.
Brandon Christman:
thanks for spinning that up! I really appreciate it. I just pulled your code down and it actually is experiencing the same behavior my attempt is.
that, when you start to nest things and nest them a few layers deep and move them back to the root level or even around within one another, the positions aren't always resetting. Which causes there to be two things with position 1 and it's a toss up which gets rendered first.
you only notice this behavior if you look at the DB directly or through the browser if you move some stuff around and refresh after each move.
I think it has to be a bug with acts_as_list how it handles nesting.
I'm going to keep digging but if you find a solution I would love if you share and I'll do the same! Thanks!
Will:
You can see it working in this cms mess-around I was playing with last winter.
https://github.com/asecondw...
alxg:
Here I simplified a little, reproduced the application in my repository https://github.com/gordienk...
Brandon Christman:
This is great! Would be willing to share the source code on this?
it's exactly what I'm looking for in my current project.
thanks!
comments powered by Disqus