Rails CRUD Example with Knockout.js

Source code available at github

app/controllers/posts_controller.rb
class PostsController < ApplicationController
Return all posts as json.
  def index
    @posts = Post.all
 
    respond_to do |format|
      format.json { render :json => @posts }
      format.html # index.html.erb
    end
  end
 
The show action is not used here. A selected record is pulled from the client-side array.
  def show
    @post = Post.find(params[:id])
 
    respond_to do |format|
      format.html # show.html.erb
    end
  end
 
  def new
    @post = Post.new
 
    respond_to do |format|
      format.html # new.html.erb
    end
  end
 
  def edit
    @post = Post.find(params[:id])
  end
 
  def create
    @post = Post.new(params[:post])
 
    respond_to do |format|
      if @post.save
        format.html { redirect_to(@post, :notice => 'Post was successfully created.') }
After creation, the record is sent back to the browser with its id.
        format.json { render :json => @post}
      else
        format.html { render :action => "new" }
Validation errors are sent back to the browser. Convert errors to an array, because it is easier to handle this way in the error template.
        format.json { render :json => @post.errors.to_a, :status => :unprocessable_entity }
      end
    end
  end
 
  def update
    @post = Post.find(params[:id])
 
    respond_to do |format|
      if @post.update_attributes(params[:post])
        format.html { redirect_to(@post, :notice => 'Post was successfully updated.') }
The updated record is sent back to the browser.
        format.json { render :json => @post}
      else
        format.html { render :action => "edit" }
        format.json { render :json => @post.errors.to_a, :status => :unprocessable_entity }
      end
    end
  end
 
  def destroy
    @post = Post.find(params[:id])
    @post.destroy
 
    respond_to do |format|
      format.html { redirect_to(posts_url) }
Convert status to json and send it back.
      format.json { render :json => 'ok'.to_json }
    end
  end
end
app/models/post.rb
class Post < ActiveRecord::Base
Normal active record model. Fields can't be empty.
   validates :title, :presence => true
   validates :body, :presence => true
end
public/crud.html
<!DOCTYPE html>
<html>
  <head>
    <title>Rails Knockout Crud Example</title>
    <link href="/stylesheets/scaffold.css" media="screen" rel="stylesheet" type="text/css" />
    <script src="/javascripts/jquery.js" type="text/javascript"></script>
    <script src="/javascripts/jquery.tmpl.js" type="text/javascript"></script>
    <script src="/javascripts/knockout-1.2.1.js" type="text/javascript"></script>
    <script src="/javascripts/crud.js" type="text/javascript"></script>
 
 
  </head>
  <body>
jQuery Template to display an element of the validation error array.
    <script id="errorsTemplate" type="text/html">
      <li>
        <b><span data-bind="text: $data"/></b>
      </li>
    </script>
 
jQuery Template for the index view.
    <script id="indexTemplate" type="text/html">
      <tr>
Data bindings for post columns.
        <td data-bind="text: title"></td>
        <td data-bind="text: body"></td>
Click handler for actions. $data contains the object that was passed to the template.
        <td>
          <a data-bind="click: function() { viewModel.showAction($data) }">Show</a>
        </td>
        <td>
          <a data-bind="click: function() { viewModel.editAction($data) }">Edit</a>
        </td>
        <td>
          <a data-bind="click: function() { viewModel.destroyAction($data) }">Delete</a>
        </td>
      </tr>
    </script>
 
jQuery Template for the show view.
    <script id="showTemplate" type="text/html">
      <p>
        <b>Title</b><br/>
        <span data-bind="text: title"></span>
      </p>
      <p>
        <b>Body</b><br/>
        <span data-bind="text: body"></span>
      </p>
      <p>
Mimic Rails CRUD navigation.
        <a data-bind="click: function() { viewModel.indexAction() }">Back</a>
        <a data-bind="click: function() { viewModel.editAction($data) }">Edit</a>
      </p>
    </script>
 
jQuery Template for the edit view.
    <script id="editTemplate" type="text/html">
      <form id="form">
        <div class="field">
          <label for="post_title">Title</label><br />
          <input id="post_title" data-bind="value: title" />
 
        </div>
        <div class="field">
          <label for="post_body">Body</label><br />
          <input id="post_body" data-bind="value: body" />
        </div>
      </form>
      <div>
        <button data-bind="click: function() { viewModel.updateAction($data) }">Save</button>
        <a data-bind="click: function() { viewModel.showAction($item.selectedItem()) }">Cancel</a>
      </div>
    </script>
 
jQuery Template for the new view.
    <script id="newTemplate" type="text/html">
      <form id="form">
        <div class="field">
          <label for="post_title">Title</label><br />
          <input id="post_title" data-bind="value: title" />
 
        </div>
        <div class="field">
          <label for="post_body">Body</label><br />
          <input id="post_body" data-bind="value: body" />
        </div>
      </form>
      <div>
        <button data-bind="click: function() { viewModel.createAction($data) }">Create</button>
        <a data-bind="click: function() { viewModel.indexAction() }">Cancel</a>
      </div>
    </script>
 
 
Call error template for validation errors.
    <ul data-bind='template: {name: "errorsTemplate", foreach: errors}'></ul>
Display flash message.
    <span data-bind='text: flash'></span>
Display/hide index view as a function of currentPage.
    <div data-bind='visible: currentPage() == "index"'>
      <table>
        <thead>
          <tr>
            <th>Title</th>
            <th>Body</th>
            <th></th>
            <th></th>
            <th></th>
          </tr>
        </thead>
        <tbody data-bind='template: {name: "indexTemplate", foreach: items}'></tbody>
      </table>
      <a data-bind='click: function() { viewModel.newAction() }'>New Post</a><br />
    </div>
 
Display/hide show view as a function of currentPage. selectedItem represents the currently selected record and is part of the viewModel.
    <div data-bind='visible: currentPage() == "show"'>
      <div data-bind='template: {name: "showTemplate", data: selectedItem}'></div>
    </div>
 
Display/hide edit view as a function of currentPage. tempItem is a copy of selectedItem. tempItem is necessary because user might cancel the edit. In this case, selectedItem is untouched. selectedItem is passed to the template as an template option for the cancel link (see edit template).
    <div data-bind='visible: currentPage() == "edit"'>
      <div data-bind='template: {name: "editTemplate", data: tempItem, templateOptions: { selectedItem: selectedItem}}'></div>
    </div>
 
Display/hide new view as a function of currentPage. Also operates on tempItem.
    <div data-bind='visible: currentPage() == "new"'>
      <div data-bind='template: {name: "newTemplate", data: tempItem}'></div>
    </div>
  </body>
</html>
public/javascripts/crud.js
var viewModel = {
Rails-like flash message.
  flash: ko.observable(),
Ensures that flash message is only displayed once.
  shownOnce: ko.observable(),
Which view is displayed?
  currentPage: ko.observable(),
Errors from server-side validations.
  errors: ko.observableArray(),
All records (from index action).
  items: ko.observableArray(),
Currently selected record.
  selectedItem: ko.observable(),
tempItem. Copy of selectedItem in edit action. Also used in new action.
  tempItem: {
    id: ko.observable(),
    title: ko.observable(),
    body: ko.observable(),
    updated_at: ko.observable(),
    created_at: ko.observable()
  },
 
Sets flash message. Reset shownOnce flag.
  setFlash: function(flash) {
    this.flash(flash);
    this.shownOnce(false);
  },
Called first in all actions to ensure that flash message in only shown once.
  checkFlash: function() {
    if (this.shownOnce() == true) {
      this.flash('');
    }
  },
Resets all properties of tempItem.
  clearTempItem: function() {
    this.tempItem.id('');
    this.tempItem.title('');
    this.tempItem.body('');
    this.tempItem.updated_at('');
    this.tempItem.created_at('');
  },
Copies properties from selectedItem to tempItem. Used in edit action.
  prepareTempItem : function() {
    this.tempItem.id(ko.utils.unwrapObservable(this.selectedItem().id));
    this.tempItem.title(ko.utils.unwrapObservable(this.selectedItem().title));
    this.tempItem.body(ko.utils.unwrapObservable(this.selectedItem().body));
    this.tempItem.updated_at(ko.utils.unwrapObservable(this.selectedItem().updated_at));
    this.tempItem.created_at(ko.utils.unwrapObservable(this.selectedItem().created_at));
  },
Index action. Fetches all records from the server and stores them in the items array.
  indexAction: function() {
    this.checkFlash();
    $.getJSON('/posts.json', function(data) {
      viewModel.items(data);
      viewModel.currentPage('index');
      viewModel.shownOnce(true);
    });
  },
Show action. Displays selected item.
  showAction: function(itemToShow) {
    this.checkFlash();
    this.errors([]);
    this.selectedItem(itemToShow);
    this.currentPage('show');
    this.shownOnce(true);
  },
New action. Displays form for new record.
  newAction: function() {
    this.checkFlash();
    this.currentPage('new');
    this.clearTempItem();
    this.shownOnce(true);
  },
Edit action. Displays edit form.
  editAction: function(itemToEdit) {
    this.checkFlash();
    this.selectedItem(itemToEdit);
    this.prepareTempItem();
    this.currentPage('edit');
    this.shownOnce(true);
  },
Create action, creates a new record in the Rails backend with a POST request.
  createAction: function(itemToCreate) {
    var json_data = ko.toJS(itemToCreate);
    $.ajax({
      type: 'POST',
      url: '/posts.json',
      data: {
Wraps properties in a "post" property, easier to handle for Rails.
        post: json_data
      },
      dataType: "json",
      success: function(createdItem) {
        viewModel.errors([]);
        viewModel.setFlash('Post successfully created.');
        viewModel.clearTempItem();
        viewModel.showAction(createdItem);
      },
      error: function(msg) {
        viewModel.errors(JSON.parse(msg.responseText));
      }
    });
  },
Update action. Updates an existing record with a PUT request.
  updateAction: function(itemToUpdate) {
    var json_data = ko.toJS(itemToUpdate);
    delete json_data.id;
    delete json_data.created_at;
    delete json_data.updated_at;
 
    $.ajax({
      type: 'PUT',
      url: '/posts/' + itemToUpdate.id() + '.json',
      data: {
        post: json_data
      },
      dataType: "json",
      success: function(updatedItem) {
        viewModel.errors([]);
        viewModel.setFlash('Post successfully updated.');
        viewModel.showAction(updatedItem);
      },
      error: function(msg) {
        viewModel.errors(JSON.parse(msg.responseText));
      }
    });
  },
Destroy action. Destroys an existing record with a DELETE request.
  destroyAction: function(itemToDestroy) {
    if (confirm('Are you sure?')) {
      $.ajax({
        type: "DELETE",
        url: '/posts/' + itemToDestroy.id + '.json',
        dataType: "json",
        success: function(){
          viewModel.errors([]);
          viewModel.setFlash('Post successfully deleted.');
          viewModel.indexAction();
        },
        error: function(msg) {
          viewModel.errors(JSON.parse(msg.responseText));
        }
      });
    }
  }
};
 
Set up bindings and call index action.
$(document).ready(function() {
  ko.applyBindings(viewModel);
  viewModel.indexAction();
  viewModel.clearTempItem();
});