ruby-on-railsajaxactiverecordacts-as-list

Rails: How can I incrementally add items to a sorted list in one view


I am trying to create a set list app for musicians. Users can upload their repertoire of songs & pick songs from a list of those songs to populate a set list for a specific performance.

See image: setlist_screenshot. Clicking the arrow icon on the songs on the left should add the song to a sortable list on the right.

Models in question:

class Setlist < ActiveRecord::Base
  belongs_to :organization
  # join between Set Lists & Arrangements(songs)
  has_many :items, -> { order(position: :asc) }
  has_many :arrangements, through: :items
end

-

class Item < ActiveRecord::Base
  # join between Set Lists & Arrangements(songs)
  belongs_to :arrangement
  belongs_to :setlist
  acts_as_list scope: :setlist
end

-

class Arrangement < ActiveRecord::Base
  belongs_to :song
  belongs_to :organization
  has_many :attachments     
  # join between Set Lists & Arrangements(songs)
  has_many :items
  has_many :setlists, through: :items
end

Basically a setlist has many arrangements(which are songs) thru items(join table), and arrangements(songs) of course can be on many setlists.

The code I have in the view for the button to add a song is:

<%= link_to(organization_setlists_path(:arrangement_id => song.arrangements.first.id, setlist_id: @new_set.id, remote: true), method: "post", class: "secondary-content" )

which is a post request (including params for the song's arrangement id & id of a Setlist.new made in the new action) to the create action of the setlists controller, as below:

class SetlistsController < ApplicationController
before_action :authenticate_user!

def new
    if @new_set.nil?
        @new_set = Setlist.create
        @new_item = Item.new 
    end
end

def create
    @arrangement = Arrangement.find(params[:arrangement_id])
    @new_item = Item.new 
    @new_item.setlist_id = Setlist.find(params[:setlist_id])
    @new_item.arrangement_id = @arrangement.id
    if @new_item.save
        flash[:message] = "Song Sucessfully Added!"
    else
        flash[:message] = "Something Went Wrong"
    end
end  

Ideally, I would like a new item to be created(remotely) for the same setlist after each click of the add/arrow button, until the user is done creating that setlist. To display that on the right side, I have:

<% if @new_set && @new_set.items.count >=1 %>
    <ul>
    <% @new_set.items.each do |item| %>
        <li class="center-align"><%= item.arrangement.title %></li>
    <% end %>
    </ul>
<% else %>
    <p class="center-align">Add Songs From The List On The Left</p>
<% end %>

Eventually, the user should be able to finalize it and move on to creating another set list if they want.

I really have no idea how to go about this other than what I have done here, which is not working. The issues are:

For one, the create action is actually creating an item & assigning the new item the foreign key for arrangements but not for the setlists for some reason, even tho the setlist id is being passed in correctly from the link_to params.

Also, each time the page is refreshed, a new set list is generated from what I have in the new action, so each item added will go into a setlist, then a blank set list is generated, so nothing will ever appear on the right.

And finally, this just seems messy all together. Anyone have a better design strategy to accomplish this? Any help would be severely appreciated!!!


Solution

  • The main issue here is that your controller violates conventions.

    Your SetlistController should create a Setlist when the user POSTs to /setlists.

    If you want to create a new child record you would POST to /setlists/:setlist_id/items. This would be handled by ItemsController.

    # routes.rb
    resources :setlists, shallow: true do
      resources :items
    end
    

    class ItemsController < ApplicationController
    
      respond_to :html
      before_action :find_setlist!, only: [:create, :index]
    
      def create
        @item = @setlist.items.build(item_params)
        @item.save
        respond_with @item
      end
    
      # ...
    
      private
        def find_setlist!
          @setlist = Setlist.includes(:items).find!(params[:setlist_id])
        end
    
        def items_params
           params.require(:item)
                 .permit(
                    :arrangement_id
                 )
        end
    end
    

    <% @arrangements.each do |a| %>
      <%= button_to 'Add to set', setlist_items_path(@setlist), 
          arrangement_id: a.id, remote: true 
      %>
    <% end %>
    

    An alternative is to use accepts_nested_attibutes_for :items to allow the Setlist to accept the parameters for the items as well.

    The key difference here is when adding additional items to an existing setlist it would for example use:

    PATCH /setlist/:id
    params: {
      item_attributes: [
        {
          arrangement_id: 2
        },
        {
          arrangement_id: 3
        }
      ]
    } 
    

    The real benefit is that the user can have performed multiple operations such as adding/removing and you just need to push the single setlist to the database to update the state, instead of using multiple POST / DELETE calls for each child record.

    class Setlist < ActiveRecord::Base
      belongs_to :organization
      # join between Set Lists & Arrangements(songs)
      has_many :items, -> { order(position: :asc) }
      has_many :arrangements, through: :items
      accepts_nested_attributes_for :items, allow_destroy: true
    end
    

    class SetlistsController < ApplicationController
    
      respond_to :html
      before_action :set_setlist, only: [:show, :edit, :update, :destroy]
    
      def new
        @setlist = Setlist.new
        @setlist.items.build 
      end
    
      def create
        @setlist = Setlist.new(setlist_params)
        @setlist.save
        respond_with @setlist
      end
    
      def update
        @setlist.update(setlist_params)
        @setlist.items.build
        respond_with @setlist
      end
    
      # ...
    
      private
        def set_setlist
          @setlist = Setlist.includes(:items).find(params[:id])
        end
    
        def setlist_params
          params.require(:setlist)
                .permit(
                  :foo, :bar # attributes for the list
                  items_attributes: [ :position, :arrangement_id, :_destroy ]
                )
        end
    end
    

    The basic form setup to support this would look something like this:

    <%= form_for(@setlist) do |f| %>
      <%= fields_for :items do |item| %>
        <%= item.number_field :postition %>
        <%= item.collection_select(:arrangement_id, @setlist.organization.arrangements, :id, :name) %>
        <%= item.checkbox :_destroy, label: 'Delete?' %>
      <% end %>
    <% end %>
    

    Of course this just a plain old synchronous HTML form - adding AJAX functionality and drag'n'drop re-ordering etc is a bit out of scope.

    Note that these solutions are not exclusive. You could easily have both options (nested attributes and a designated controller) in your application depending on if your requirements need you to be able to manipulate the child records individually as well or if you want to provide it as part of your API.