Drag and Drop with Interact.js

Episode #75 by Teacher's Avatar David Kimura

Summary

Using Interact.js to create draggable and droppable items in our view, we can use AJAX callbacks on events to interact with our Ruby on Rails application. Also, learn how to use Ruby Assets to manage our Javascript Libraries.
rails javascript assets ajax view 15:20

Resources

A better way to write the original lookup for which foods can be created

@foods = Food.joins(recipes: :ingredient).where(ingredients: {id: params[:ingredients]})

Summary

# Gemfile
source 'https://rails-assets.org' do
  gem 'rails-assets-interact'
end

# application.js
//= require interact
...
var dragMoveListener;

dragMoveListener = function(event) {
  var target, x, y;
  target = event.target;
  x = (parseFloat(target.getAttribute('data-x')) || 0) + event.dx;
  y = (parseFloat(target.getAttribute('data-y')) || 0) + event.dy;
  target.style.webkitTransform = target.style.transform = 'translate(' + x + 'px, ' + y + 'px)';
  target.setAttribute('data-x', x);
  return target.setAttribute('data-y', y);
};

window.dragMoveListener = dragMoveListener;

interact('*[data-draggable="true"]').draggable({
  inertia: true,
  autoScroll: true,
  onmove: dragMoveListener
});


$(document).on('turbolinks:load', function(){
  interact('#favorite_foods').dropzone({
    accept: '*[data-draggable="true"]',
    overlap: 0.75,
    ondropactivate: function(event) {},
    ondragenter: function(event) {
      event.target.classList.add('drop-target');
      event.relatedTarget.classList.add('can-drop');
      return $.get(event.relatedTarget.attributes['data-url'].value, {
        favorite: true
      });
    },
    ondragleave: function(event) {
      event.target.classList.remove('drop-target');
      event.relatedTarget.classList.remove('can-drop');
      return $.get(event.relatedTarget.attributes['data-url'].value, {
        favorite: false
      });
    },
    ondrop: function(event) {},
    ondropdeactivate: function(event) {
      event.target.classList.remove('drop-active');
      return event.target.classList.remove('drop-target');
    }
  });


  var ingredients = [];

  interact('#have_ingredients').dropzone({
    accept: '*[data-draggable="true"]',
    overlap: 0.75,
    ondropactivate: function(event) {},
    ondragenter: function(event) {
      event.target.classList.add('drop-target');
      event.relatedTarget.classList.add('can-drop');
      ingredients.push(event.relatedTarget.attributes['data-ingredient'].value);
      return $.get(event.relatedTarget.attributes['data-url'].value, { ingredients: ingredients });
    },
    ondragleave: function(event) {
      event.target.classList.remove('drop-target');
      event.relatedTarget.classList.remove('can-drop');
      ingredients = jQuery.grep(ingredients, function(value) {
        return value != event.relatedTarget.attributes['data-ingredient'].value;
      });
      return $.get(event.relatedTarget.attributes['data-url'].value, { ingredients: ingredients });
    },
    ondrop: function(event) {},
    ondropdeactivate: function(event) {
      event.target.classList.remove('drop-active');
      return event.target.classList.remove('drop-target');
    }
  });
});

# coffeescript equivalent
dragMoveListener = undefined

dragMoveListener = (event) ->
  target = undefined
  x = undefined
  y = undefined
  target = event.target
  x = (parseFloat(target.getAttribute('data-x')) or 0) + event.dx
  y = (parseFloat(target.getAttribute('data-y')) or 0) + event.dy
  target.style.webkitTransform = target.style.transform = 'translate(' + x + 'px, ' + y + 'px)'
  target.setAttribute 'data-x', x
  target.setAttribute 'data-y', y

window.dragMoveListener = dragMoveListener
interact('*[data-draggable="true"]').draggable
  inertia: true
  autoScroll: true
  onmove: dragMoveListener
$(document).on 'turbolinks:load', ->
  interact('#favorite_foods').dropzone
    accept: '*[data-draggable="true"]'
    overlap: 0.75
    ondropactivate: (event) ->
    ondragenter: (event) ->
      event.target.classList.add 'drop-target'
      event.relatedTarget.classList.add 'can-drop'
      $.get event.relatedTarget.attributes['data-url'].value, favorite: true
    ondragleave: (event) ->
      event.target.classList.remove 'drop-target'
      event.relatedTarget.classList.remove 'can-drop'
      $.get event.relatedTarget.attributes['data-url'].value, favorite: false
    ondrop: (event) ->
    ondropdeactivate: (event) ->
      event.target.classList.remove 'drop-active'
      event.target.classList.remove 'drop-target'
  ingredients = []
  interact('#have_ingredients').dropzone
    accept: '*[data-draggable="true"]'
    overlap: 0.75
    ondropactivate: (event) ->
    ondragenter: (event) ->
      event.target.classList.add 'drop-target'
      event.relatedTarget.classList.add 'can-drop'
      ingredients.push event.relatedTarget.attributes['data-ingredient'].value
      $.get event.relatedTarget.attributes['data-url'].value, ingredients: ingredients
    ondragleave: (event) ->
      event.target.classList.remove 'drop-target'
      event.relatedTarget.classList.remove 'can-drop'
      ingredients = jQuery.grep(ingredients, (value) ->
        value != event.relatedTarget.attributes['data-ingredient'].value
      )
      $.get event.relatedTarget.attributes['data-url'].value, ingredients: ingredients
    ondrop: (event) ->
    ondropdeactivate: (event) ->
      event.target.classList.remove 'drop-active'
      event.target.classList.remove 'drop-target'
  return

# visitors.scss
.dropzone {
  height: 180px;
  background-color: #ccc;
  border: dashed 4px transparent;
  border-radius: 4px;
  margin: 10px auto 30px;
  padding: 10px;
  width: 80%;
  transition: background-color 0.3s;
}

.drop-active {
  border-color: #aaa;
}

.drop-target {
  background-color: #29e;
  border-color: #fff;
  border-style: solid;
}

.drag-drop {
  display: inline-block;
  min-width: 40px;
  padding: 1em 0.75em;

  color: #fff;
  background-color: #29e;
  border: solid 2px #fff;

  -webkit-transform: translate(0px, 0px);
          transform: translate(0px, 0px);

  transition: background-color 0.3s;
}

.drag-drop.can-drop {
  color: #000;
  background-color: #4e4;
}

# foods_controller.rb
class FoodsController < ApplicationController
  def opinion_on_food
    @food = Food.find(params[:id])
    @food.update_attribute(:favorite, params[:favorite])
    @food.save
    head :ok
  end

  def what_to_cook
    @foods = Food.includes(:recipes).all.select {|i| i.recipes.map(&:ingredient_id).to_set.subset?(params[:ingredients].to_a.map(&:to_i).to_set) }
  end
end

# models/food.rb
class Food < ApplicationRecord
  has_many :recipes, dependent: :destroy, class_name: 'Recipe'
  has_many :ingredients, through: :recipes

  def self.favorite_foods
    where(favorite: true)
  end

  def self.no_opinion_foods
    where(favorite: false)
  end
end

# models/ingredient.rb
class Ingredient < ApplicationRecord
  has_many :recipes, dependent: :destroy, class_name: 'Recipe'
  has_many :foods, through: :recipes
end

# models/recipe.rb
class Recipe < ApplicationRecord
  belongs_to :food
  belongs_to :ingredient
end

# visitors_controller.rb
class VisitorsController < ApplicationController
  def favorite_foods
  end

  def ingredients
    @ingredients = Ingredient.all
  end
end

# foods/_food.html.erb
<li class='list-group-item'>
  <strong><%= food.name %></strong>
  <%= content_tag :span, 'and it is one of your favorites' if food.favorite? %>
</li>

# foods/what_to_cook.js.erb
<% if @foods.size > 0 %>
$('#foods').html('<%= j render @foods %>');
<% else %>
$('#foods').html('<li class="list-group-item">Sorry, you may go hungry...</li>');
<% end %>

# visitors/favorite_foods.html.erb
<% Food.no_opinion_foods.each do |food| %>
  <%= content_tag :div, food.name, class: 'drag-drop', data: { draggable: true, url: opinion_on_food_path(food) } %>
<% end %>
<div id="favorite_foods" class="dropzone">
  <h1>Favorite Foods</h1>
  <% Food.favorite_foods.each do |food| %>
    <%= content_tag :div, food.name, class: 'drag-drop can-drop', data: { draggable: true, url: opinion_on_food_path(food) } %>
  <% end %>
</div>

# visitors/ingredients.html.erb
<% @ingredients.each do |ingredient| %>
  <%= content_tag :div, ingredient.name, class: 'drag-drop', data: { draggable: true, url: what_to_cook_path, ingredient: ingredient.id } %>
<% end %>
<div id="have_ingredients" class="dropzone">
  <legend>Ingredients I have</legend>
</div>

<div class="panel panel-primary">
  <div class="panel-heading">
    <h3 class="panel-title">You have the ingredients to make</h3>
  </div>
  <ul id='foods' class='list-group'>
    <li class="list-group-item">Sorry, you may go hungry...</li>
  </ul>
</div>