Admin MVC Standards

Models

Models represent real-world things.  For instance, let's say you're working with dogs.  Let's create a class to represent a dog.

class Dog < ActiveRecord::Base  
  attr_accessible :id, :name, :breed, :weight
end

For our use, all we want to keep up with is the dog's name, breed, and weight. 

Routes

We need a way to interact with our dog model. The way we do that is by routing certain HTTP requests to certain methods of a controller. We'll get to the controller below, but let's talk about the routes we need first. There are many ways of setting up routes, but we prefer to use RESTful URLs for all of our models.  This means that each instance of a model has it's own unique URL and that you interact with the models by sending different types of HTTP requests to the unique URL.

For our dog model example, we'll use two main URLs:

/admin/dogs
/admin/dogs/:id

Here's the route file for this:

MyExample::Application.routes.draw do
  get    "/admin/dogs"     => "dogs#admin_index"        
  get    "/admin/dogs/:id" => "dogs#admin_edit"  
  put    "/admin/dogs/:id" => "dogs#admin_update"               
  post   "/admin/dogs"     => "dogs#admin_add"          
  delete "/admin/dogs/:id" => "dogs#admin_delete"     
end

As you can see, the above route file covers viewing a list of dogs, adding to the list of dogs, editing a dog, updating a dog, and deleting a dog.  Now let's add a few more that we'll talk about later in the controllers.

MyExample::Application.routes.draw do
  get    "/admin/dogs"      => "dogs#admin_index"
  get    "/admin/dogs"      => "dogs#admin_json"        
  get    "/admin/dogs"      => "dogs#admin_json_single"
  get    "/admin/dogs/:id"  => "dogs#admin_edit"
  put    "/admin/dogs/bulk" => "dogs#admin_bulk_update"  
  put    "/admin/dogs/:id"  => "dogs#admin_update"
  post   "/admin/dogs/bulk" => "dogs#admin_bulk_add"
  post   "/admin/dogs"      => "dogs#admin_add"
  delete "/admin/dogs/bulk" => "dogs#admin_bulk_delete"
  delete "/admin/dogs/:id"  => "dogs#admin_delete"
end

Controllers

Let's start with a simple controller that implements the standard admin methods.

class DogsController < Caboose::ApplicationController    
      
  def admin_index()       end
  def admin_json()        end
  def admin_json_single() end
  def admin_edit()        end
  def admin_update()      end
  def admin_bulk_update() end        
  def admin_add()         end    
  def admin_bulk_add()    end
  def admin_delete()      end
  def admin_bulk_delete() end
      
end

And now let's fill in the details for each method.

The admin_index method simply makes sure the user is allowed to get to the page.  This is done because the model_binder/index_table javascript will go get the list of dogs.

  # GET /admin/dogs
  def admin_index
    return if !user_is_allowed('dogs', 'view')      
    render :layout => 'caboose/admin'
  end

The admin_json method is what the index_table javascript will call to get an array of all the dogs. As you can see, there are a few parameters that can be passed that will filter the dogs that are returned.

  # GET /admin/dogs/json
  def admin_json
    return if !user_is_allowed('dogs', 'view')
    pager = Caboose::PageBarGenerator.new(params, {      
      'name_like'      => '',        
      'breed_like'     => '',
      'weight_like'    => '',
    }, {
      'model'          => 'Dog',
      'sort'           => 'name',
      'desc'           => false,
      'base_url'       => '/admin/dogs',
      'items_per_page' => 25,
      'use_url_params' => false
    })
    render :json => {
      :pager => pager,
      :models => pager.items
    }      
  end

The admin_json_single method is what the index_table javascript uses to refresh a single dog from the database.

  # GET /admin/dogs/:id/json
  def admin_json_single
    return if !user_is_allowed('dogs', 'view')
    d = Dog.find(params[:id])
    render :json => d      
  end

The admin_edit method just gets the dog from the database corresponding to the id given in the URL, then sends it on to the admin edit view.

  # GET /admin/dogs/:id
  def admin_edit
    return if !user_is_allowed('dogs', 'edit')    
    @dog = Dog.find(params[:id])
    render :layout => 'caboose/admin'
  end

The admin_update method updates the given attribute for the object corresponding to the given id.  This method is usually called from javascript ModelBinder object.

  # PUT /admin/dogs/:id
  def admin_update
    return if !user_is_allowed('dogs', 'edit')
    
    resp = Caboose::StdClass.new
    dog = Dog.find(params[:id])    
    
    save = true    
    params.each do |k,v|
      case name                            
        when 'name'    then dog.name   = value
        when 'breed'   then dog.breed  = value
        when 'weight'  then dog.weight = value        
      end
    end
    resp.success = save && d.save
    render :json => resp
  end

The admin_bulk_update method does the same thing as the admin_update method, but it update each dog correspoding to every id given.  This method is called from the ModelBinder/IndexTable javascript object.

  # PUT /admin/dogs/bulk
  def admin_bulk_update
    return unless user_is_allowed_to('edit', 'dogs')
  
    resp = Caboose::StdClass.new    
    dogs = params[:model_ids].collect{ |dog_id| Dog.find(dog_id) }
  
    save = true
    params.each do |k,v|
      case k
        when 'name'    then dogs.each{ |dog| dog.name   = v }
        when 'breed'   then dogs.each{ |dog| dog.breed  = v }
        when 'weight'  then dogs.each{ |dog| dog.weight = v }                              
      end        
    end
    dogs.each{ |dog| dog.save }
  
    resp.success = true
    render :json => resp
  end

The admin_add method takes the given data, creates a new dog, then returns the id of the new dog.  This method is often called from the ModelBinder/IndexTable javascript object, but may also be called from a custom new model page.

  # POST /admin/dogs
  def admin_add
    return if !user_is_allowed('dogs', 'add')
    
    resp = Caboose::StdClass.new
        
    if    params[:name].length   == 0 then resp.error = "The name cannot be empty."
    elsif params[:breed].length  == 0 then resp.error = "The breed cannot be empty."
    elsif params[:weight].length == 0 then resp.error = "The weight cannot be empty."
    else
      d = Dog.new(         
        :name   => params[:name],
        :breed  => params[:breed],
        :weight => params[:weight]
      )
      d.save
      resp.new_id = d.id
      resp.redirect = "/admin/dogs/#{d.id}"
    end
    render :json => resp    
  end

The admin_bulk_add method takes CSV data, checks it, and upon a successful check, creates objects for each row of data.  This method is usually called from the ModelBinder/IndexTable javascript object.

  # POST /admin/dogs/bulk
  def admin_bulk_add
    return if !user_is_allowed('dogs', 'add')
    
    resp = Caboose::StdClass.new
        
    # Check for data integrity first
    CSV.parse(params[:csv_data]).each do |row|
      if    row[0].length == 0 then resp.error = "The name   cannot be empty." and break
      elsif row[1].length == 0 then resp.error = "The breed  cannot be empty." and break
      elsif row[2].length == 0 then resp.error = "The weight cannot be empty." and break
      end            
    end
    
    if resp.error.nil?
      CSV.parse(params[:csv_data]).each do |row|                            
        Dog.create(
          :name => row[0],
          :breed => row[1],
          :weight => row[2]
        )        
      end
      resp.success = true
    end
    
    render :json => resp
  end

The admin_delete method simply deletes the object corresponding to the given id.

  # DELETE /admin/dogs/:id
  def admin_delete
    return if !user_is_allowed('dogs', 'delete')
    Dog.find(params[:id]).destroy      
    render :json => { :success => true }
  end

The admin_bulk_delete method deletes the objects corresponding to the given model ids.  This method is usually called from the ModelBinder/IndexTable javascript object.

  # DELETE /admin/dogs/bulk
  def admin_bulk_delete
    return if !user_is_allowed('dogs', 'delete')
      
    resp = Caboose::StdClass.new
    params[:model_ids].each do |dog_id|
      Dog.find(dog_id).destroy      
    end
    resp.success = true
    render :json => resp
  end

Views

Because the ModelBinder/IndexTable does so much, for most models, there are only two views that need to be written, one for the admin_index method and another for the admin_edit method.

The admin_index view sets up the ModelBinder/IndexTable javascript object.

<h1>Dogs</h1>
<div id='dogs'></div>
<div id='message'></div>

<% content_for :caboose_js do %>
<%= javascript_include_tag "caboose/model/all" %>
<script type='text/javascript'>

$(document).ready(function() {
  var that = this;
  var table = new IndexTable({    
    form_authenticity_token: '<%= form_authenticity_token %>',
    container: 'dogs',
    base_url: '/admin/dogs',
    allow_bulk_edit: true,
    allow_bulk_delete: true,
    allow_duplicate: false,
    allow_advanced_edit: true,
    fields: [      
      { show: true  , name: 'name'   , nice_name: 'Name'   , sort: 'name'   , type: 'text' , value: function(d) { return d.name;   }, width: 75, align: 'left', bulk_edit: true },
      { show: true  , name: 'breed'  , nice_name: 'Breed'  , sort: 'breed'  , type: 'text' , value: function(d) { return d.breed;  }, width: 75, align: 'left', bulk_edit: true },
      { show: true  , name: 'weight' , nice_name: 'Weight' , sort: 'weight' , type: 'text' , value: function(d) { return d.weight; }, width: 75, align: 'left', bulk_edit: true }                                            
    ],
    new_model_text: 'New Dog',
    new_model_fields: [
      { name: 'name'   , nice_name: 'Name'   , type: 'text' },
      { name: 'breed'  , nice_name: 'Breed'  , type: 'text' },
      { name: 'weight' , nice_name: 'Weight' , type: 'text' }                  
    ],
    bulk_import_fields: ['name', 'breed', 'weight'],
    bulk_import_url: '/admin/dogs/bulk'
  });        
});

</script>
<% end %>

The admin_edit method is not completely necessary because the ModelBinder/IndexTable javascript objects allows models to be edited inline directly on the admin_index page.  However, there are times where it's easier for the user to edit models on another page.  In this case, the admin_edit view will be needed.  The admin_edit view takes the model from the admin_edit controller method and sets up a ModelBinder javascript object.  The admin_edit view also sets up a delete button that sends a delete ajax call.

<%
d = @dog
%>

<h1>Edit Dog</h1>

<p><div id="dog_<%= d.id %>_name"   ></div></p>
<p><div id="dog_<%= d.id %>_breed"  ></div></p>
<p><div id="dog_<%= d.id %>_weight" ></div></p>

<div id="message"></div>
  
<p>
<input type="button" value="< Back" onclick="window.location='/admin/dogs';" />
<input type="button" value="Delete Dog" onclick="delete_dog(<%= d.id %>)" />
</p>

<% content_for :caboose_js do %>
<%= javascript_include_tag "caboose/model/all" %>
<script type='text/javascript'>

$(document).ready(function() {
  m = new ModelBinder({
    name: 'Dog',
    id: <%= d.id %>,
    update_url: '/admin/dogs/<%= d.id %>',
    authenticity_token: '<%= form_authenticity_token %>',
    attributes: [      
      { name: 'name'   , nice_name: 'Name'   , type: 'text' , value: <%= raw Caboose.json(d.name)   %> , width: 400 },
      { name: 'breed'  , nice_name: 'Breed'  , type: 'text' , value: <%= raw Caboose.json(d.breed)  %> , width: 400 }, 
      { name: 'weight' , nice_name: 'Weight' , type: 'text' , value: <%= raw Caboose.json(d.weight) %> , width: 400 }      
    ]
  });
});

function delete_dog(dog_id, confirm) 
{
  if (!confirm) 
  {
    var p = $('

').addClass('note error') .append("Are you sure you want to delete the dog? ") .append($('').attr('type', 'button').val('Yes').click(function() { delete_dog(dog_id, true); })).append(' ') .append($('').attr('type', 'button').val('No').click(function() { $('#message').empty(); })); $('#message').empty().append(p); return; } $('#message').html("

Deleting the dog...

"); $.ajax({ url: '/admin/dogs/' + dog_id, type: 'delete', success: function(resp) { if (resp.error) $('#message').html("

" + resp.error + "

"); if (resp.redirect) window.location = resp.redirect; } }); } </script> <% end %>